| 1 | # -*- coding: utf-8 -*- |
| 2 | """ |
| 3 | 阶段2: 角色/场景设计智能体 |
| 4 | 基于阶段1的剧本JSON生成角色4视图和场景全景图 |
| 5 | 支持单项重新生成、历史版本切换 |
| 6 | 图片以 character_id / setting_id 命名 |
| 7 | """ |
| 8 | |
| 9 | import os |
| 10 | import re |
| 11 | import json |
| 12 | import glob |
| 13 | import asyncio |
| 14 | import logging |
| 15 | from typing import Any, Optional, Dict, List |
| 16 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 17 | |
| 18 | from .base_agent import AgentInterface |
| 19 | from prompts.loader import load_prompt |
| 20 | |
| 21 | logger = logging.getLogger(__name__) |
| 22 | |
| 23 | |
| 24 | class CharacterDesignerAgent(AgentInterface): |
| 25 | """角色/场景设计:从剧本JSON读取描述 → 生成角色4视图 + 场景全景图""" |
| 26 | |
| 27 | def __init__(self): |
| 28 | super().__init__(name="CharacterDesigner") |
| 29 | |
| 30 | # ─── 提示词模板 ─── |
| 31 | |
| 32 | @staticmethod |
| 33 | def _char_prompt(name: str, desc: str, style: str, species: str = "") -> str: |
| 34 | """角色4视图提示词 - 直接加载 character_zh 模板""" |
| 35 | # 加载基础角色提示词模板 |
| 36 | template = load_prompt('character', 'character', 'zh') |
| 37 | # 替换角色信息 |
| 38 | return template.format(name=name, desc=desc, style=style) |
| 39 | |
| 40 | @staticmethod |
| 41 | def _setting_prompt(name: str, desc: str, style: str) -> str: |
| 42 | """场景全景图提示词 - 直接加载 setting_zh 模板""" |
| 43 | # 加载基础场景提示词模板 |
| 44 | template = load_prompt('setting', 'setting', 'zh') |
| 45 | # 替换场景信息 |
| 46 | return template.format(name=name, desc=desc, style=style) |
| 47 | |
| 48 | @staticmethod |
| 49 | def _apply_eval_feedback_to_prompt(base_prompt: str, eval_result: dict, iteration: int) -> str: |
| 50 | suggested_prompt = (eval_result.get('suggested_prompt') or '').strip() |
| 51 | if suggested_prompt: |
| 52 | return suggested_prompt |
| 53 | |
| 54 | hard_failures = eval_result.get('hard_failures') or [] |
| 55 | soft_issues = eval_result.get('soft_issues') or [] |
| 56 | issues = eval_result.get('issues') or [] |
| 57 | suggestion = (eval_result.get('suggestion') or '').strip() |
| 58 | |
| 59 | feedback_lines = [] |
| 60 | if hard_failures: |
| 61 | feedback_lines.append("硬性失败项:" + ";".join(map(str, hard_failures))) |
| 62 | if issues: |
| 63 | feedback_lines.append("主要问题:" + ";".join(map(str, issues))) |
| 64 | if soft_issues: |
| 65 | feedback_lines.append("软性问题:" + ";".join(map(str, soft_issues))) |
| 66 | if suggestion: |
| 67 | feedback_lines.append("修改建议:" + suggestion) |
| 68 | if not feedback_lines: |
| 69 | return base_prompt |
| 70 | |
| 71 | return ( |
| 72 | f"{base_prompt}\n\n" |
| 73 | f"【第{iteration + 1}轮VLM评估反馈】\n" |
| 74 | f"上一轮生成未通过评估,请在下一轮生成时优先修正以下问题,同时保持原始角色/场景设定不变:\n" |
| 75 | + "\n".join(f"- {line}" for line in feedback_lines) |
| 76 | ) |
| 77 | |
| 78 | # ─── 文件管理(基于唯一ID) ─── |
| 79 | |
| 80 | @staticmethod |
| 81 | def _asset_base(sid: str) -> str: |
| 82 | return os.path.join('code/result/image', str(sid), 'Assets') |
| 83 | |
| 84 | def _list_versions(self, sid: str, asset_type: str, asset_id: str) -> List[str]: |
| 85 | """列出某个素材的所有历史版本文件路径,按时间排序 |
| 86 | 文件命名: {asset_id}.png, {asset_id}_v2.png, {asset_id}_v3.png, ... |
| 87 | """ |
| 88 | adir = os.path.join(self._asset_base(sid), asset_type) |
| 89 | files = [] |
| 90 | for ext in ("png", "jpg", "jpeg", "webp", "bmp"): |
| 91 | pattern = os.path.join(adir, f"{asset_id}*.{ext}") |
| 92 | files.extend(glob.glob(pattern)) |
| 93 | files = sorted(set(files), key=os.path.getmtime) |
| 94 | return files |
| 95 | |
| 96 | def _next_version_path(self, sid: str, asset_type: str, asset_id: str) -> str: |
| 97 | """获取下一个版本的文件路径""" |
| 98 | adir = os.path.join(self._asset_base(sid), asset_type) |
| 99 | os.makedirs(adir, exist_ok=True) |
| 100 | |
| 101 | existing = self._list_versions(sid, asset_type, asset_id) |
| 102 | if not existing: |
| 103 | return os.path.join(adir, f"{asset_id}.png") |
| 104 | |
| 105 | max_v = 1 |
| 106 | for fp in existing: |
| 107 | bn = os.path.splitext(os.path.basename(fp))[0] |
| 108 | m = re.search(r'_v(\d+)$', bn) |
| 109 | if m: |
| 110 | max_v = max(max_v, int(m.group(1))) |
| 111 | |
| 112 | return os.path.join(adir, f"{asset_id}_v{max_v + 1}.png") |
| 113 | |
| 114 | def _build_asset_info(self, sid: str, asset_type: str, |
| 115 | asset_id: str, name: str, desc: str, |
| 116 | selected_path: str = "") -> dict: |
| 117 | """构建单个素材的信息(含所有历史版本)""" |
| 118 | versions = self._list_versions(sid, asset_type, asset_id) |
| 119 | if not selected_path and versions: |
| 120 | selected_path = versions[-1] |
| 121 | status = "done" if selected_path or versions else "failed" |
| 122 | return { |
| 123 | "id": asset_id, |
| 124 | "name": name, |
| 125 | "description": desc, |
| 126 | "selected": selected_path, |
| 127 | "versions": versions, |
| 128 | "status": status, |
| 129 | } |
| 130 | |
| 131 | @staticmethod |
| 132 | def _collect_rewrite_results(artifact: dict) -> dict: |
| 133 | rewrite_results = {"characters": {}, "settings": {}} |
| 134 | if not isinstance(artifact, dict): |
| 135 | return rewrite_results |
| 136 | for key in ("characters", "settings"): |
| 137 | for item in artifact.get(key, []) if isinstance(artifact.get(key), list) else []: |
| 138 | if isinstance(item, dict) and item.get("id") and item.get("rewrite_result"): |
| 139 | rewrite_results[key][item["id"]] = item["rewrite_result"] |
| 140 | return rewrite_results |
| 141 | |
| 142 | # ─── 图片生成 ─── |
| 143 | |
| 144 | def _generate_image_with_doctor(self, img_client, *, prompt: str, model: str, |
| 145 | llm_model: str, context: dict, **kwargs) -> tuple: |
| 146 | """Generate once; if prompt-related failure is diagnosed, rewrite and retry once.""" |
| 147 | try: |
| 148 | paths = img_client.generate_image(prompt=prompt, model=model, **kwargs) |
| 149 | if not paths: |
| 150 | raise RuntimeError("Image generation returned no output") |
| 151 | return paths, prompt, None |
| 152 | except Exception as exc: |
| 153 | from .doctor_agent import DoctorAgent |
| 154 | |
| 155 | doctor = DoctorAgent(llm_model=llm_model) |
| 156 | rewrite, diagnosis, rewrite_result = doctor.maybe_rewrite_prompt( |
| 157 | stage="character_design", |
| 158 | model=model, |
| 159 | prompt=prompt, |
| 160 | error=str(exc), |
| 161 | context=context, |
| 162 | ) |
| 163 | if not rewrite: |
| 164 | logger.info("Doctor skipped character prompt rewrite: %s", diagnosis.get("reason")) |
| 165 | raise |
| 166 | |
| 167 | logger.info("Doctor rewrote character prompt: reason_type=%s reason=%s", |
| 168 | diagnosis.get("reason_type"), diagnosis.get("reason")) |
| 169 | paths = img_client.generate_image(prompt=rewrite, model=model, **kwargs) |
| 170 | if not paths: |
| 171 | raise RuntimeError("Image generation returned no output after doctor rewrite") |
| 172 | return paths, rewrite, rewrite_result |
| 173 | |
| 174 | def _build_preview(self, sid: str, chars_desc: dict, sets_desc: dict) -> dict: |
| 175 | """构建素材预览列表(含当前状态)用于前端实时显示""" |
| 176 | preview = {"characters": [], "settings": []} |
| 177 | for asset_id, info in chars_desc.items(): |
| 178 | existing = self._list_versions(sid, 'characters', asset_id) |
| 179 | preview["characters"].append({ |
| 180 | "id": asset_id, |
| 181 | "name": info.get("name", ""), |
| 182 | "description": info.get("description", ""), |
| 183 | "selected": existing[-1] if existing else "", |
| 184 | "versions": existing, |
| 185 | "status": "done" if existing else "pending", |
| 186 | }) |
| 187 | for asset_id, info in sets_desc.items(): |
| 188 | existing = self._list_versions(sid, 'settings', asset_id) |
| 189 | name = info.get("name", "") if isinstance(info, dict) else "" |
| 190 | desc = info.get("description", "") if isinstance(info, dict) else str(info) |
| 191 | preview["settings"].append({ |
| 192 | "id": asset_id, |
| 193 | "name": name, |
| 194 | "description": desc, |
| 195 | "selected": existing[-1] if existing else "", |
| 196 | "versions": existing, |
| 197 | "status": "done" if existing else "pending", |
| 198 | }) |
| 199 | return preview |
| 200 | |
| 201 | def _generate_one(self, img_client, asset_id: str, name: str, desc: str, |
| 202 | asset_type: str, style: str, species: str, |
| 203 | t2i_model: str, vlm_model: str, sid: str, |
| 204 | llm_model: str = "", max_iterations: int = 3) -> tuple: |
| 205 | """生成单个素材图并返回 (asset_id, path_or_None, eval_result) |
| 206 | |
| 207 | 评估-生成循环:如果 VLM 评估发现问题,最多重新生成 max_iterations 次 |
| 208 | """ |
| 209 | self._check_cancel() |
| 210 | |
| 211 | # 初始提示词 |
| 212 | style_prompt = self._get_style_prompt(style) |
| 213 | if asset_type == 'characters': |
| 214 | base_prompt = self._char_prompt(name, desc, style_prompt, species) |
| 215 | else: |
| 216 | base_prompt = self._setting_prompt(name, desc, style_prompt) |
| 217 | |
| 218 | video_ratio = "16:9" |
| 219 | resolution = "2K" |
| 220 | current_prompt = base_prompt |
| 221 | |
| 222 | for iteration in range(max_iterations): |
| 223 | self._check_cancel() |
| 224 | |
| 225 | save_path = self._next_version_path(sid, asset_type, asset_id) |
| 226 | save_dir = os.path.dirname(save_path) |
| 227 | |
| 228 | try: |
| 229 | paths, current_prompt, rewrite_result = self._generate_image_with_doctor( |
| 230 | img_client, |
| 231 | prompt=current_prompt, model=t2i_model, |
| 232 | llm_model=llm_model, |
| 233 | context={ |
| 234 | "asset_id": asset_id, |
| 235 | "asset_name": name, |
| 236 | "asset_type": asset_type, |
| 237 | "description": desc, |
| 238 | }, |
| 239 | session_id=str(sid), save_dir=save_dir, video_ratio=video_ratio, resolution=resolution, |
| 240 | ) |
| 241 | |
| 242 | gen = paths[0] |
| 243 | if gen != save_path: |
| 244 | if os.path.exists(save_path): |
| 245 | os.remove(save_path) |
| 246 | os.rename(gen, save_path) |
| 247 | |
| 248 | # VLM 评估 |
| 249 | eval_result = self._evaluate_with_vlm(save_path, desc, asset_type, vlm_model) |
| 250 | |
| 251 | score = eval_result.get('score', 0) |
| 252 | issues = eval_result.get('issues', []) |
| 253 | suggestion = eval_result.get('suggestion', '') |
| 254 | hard_failures = eval_result.get('hard_failures') or [] |
| 255 | if 'is_acceptable' in eval_result: |
| 256 | is_acceptable = bool(eval_result.get('is_acceptable')) and not hard_failures |
| 257 | else: |
| 258 | is_acceptable = not hard_failures and score >= 7 |
| 259 | |
| 260 | if is_acceptable: |
| 261 | logger.info(f"[{asset_type}] {name} ✓ VLM评估通过 - 评分: {score}/10") |
| 262 | else: |
| 263 | logger.warning(f"[{asset_type}] {name} ✗ VLM评估不通过 - 评分: {score}/10") |
| 264 | logger.warning(f"[{asset_type}] 问题: {issues}") |
| 265 | if hard_failures: |
| 266 | logger.warning(f"[{asset_type}] 硬性失败项: {hard_failures}") |
| 267 | if suggestion: |
| 268 | logger.warning(f"[{asset_type}] 建议: {suggestion}") |
| 269 | |
| 270 | # 检查是否需要重新生成 |
| 271 | if is_acceptable: |
| 272 | # 评估通过,返回结果 |
| 273 | return asset_id, save_path, eval_result, rewrite_result |
| 274 | else: |
| 275 | # 评估不通过,记录问题并继续循环 |
| 276 | current_prompt = self._apply_eval_feedback_to_prompt(base_prompt, eval_result, iteration) |
| 277 | logger.info(f"[{asset_type}] {name} 下一轮将使用VLM反馈优化提示词") |
| 278 | # 报告进度 |
| 279 | self._report_progress("角色设计", f"重新生成中 ({iteration + 2}/{max_iterations}): {name}", 0) |
| 280 | |
| 281 | except Exception as e: |
| 282 | logger.error(f"Asset gen failed for {asset_type} {name}({asset_id}): {e}") |
| 283 | |
| 284 | # 达到最大迭代次数,尝试使用 VLM 选择最佳图片 |
| 285 | logger.warning(f"[{asset_type}] {name} reached max iterations ({max_iterations}), trying VLM selection") |
| 286 | |
| 287 | # 收集所有生成过的版本 |
| 288 | all_versions = self._list_versions(sid, asset_type, asset_id) |
| 289 | if len(all_versions) > 1: |
| 290 | # 有多个版本,调用 VLM 选择最好的 |
| 291 | best_path, best_eval = self._select_best_with_vlm( |
| 292 | all_versions, name, desc, asset_type, species, vlm_model |
| 293 | ) |
| 294 | if best_path: |
| 295 | logger.info(f"[{asset_type}] {name} VLM selected best version: {best_path}") |
| 296 | return asset_id, best_path, best_eval, best_eval.get("rewrite_result") if isinstance(best_eval, dict) else None |
| 297 | |
| 298 | # 没有多个版本或 VLM 选择失败,返回最后一次结果 |
| 299 | return ( |
| 300 | asset_id, |
| 301 | save_path if os.path.exists(save_path) else None, |
| 302 | eval_result if 'eval_result' in locals() else None, |
| 303 | rewrite_result if 'rewrite_result' in locals() else None, |
| 304 | ) |
| 305 | |
| 306 | def _evaluate_with_vlm(self, image_path: str, description: str, asset_type: str, vlm_model: str = "qwen3.5-plus") -> dict: |
| 307 | """使用 VLM 评估生成的图片""" |
| 308 | try: |
| 309 | from models.vlm_client import VLM |
| 310 | vlm = VLM() |
| 311 | |
| 312 | # 选择评估提示词 |
| 313 | if asset_type == 'characters': |
| 314 | eval_prompt = load_prompt('character', 'eval_character', 'zh').format( |
| 315 | character_description=description |
| 316 | ) |
| 317 | else: |
| 318 | eval_prompt = load_prompt('setting', 'eval_setting', 'zh').format( |
| 319 | setting_description=description |
| 320 | ) |
| 321 | |
| 322 | result = vlm.query( |
| 323 | prompt=eval_prompt, |
| 324 | image_paths=[image_path], |
| 325 | model=vlm_model |
| 326 | ) |
| 327 | |
| 328 | # 解析结果 |
| 329 | if result and isinstance(result, list): |
| 330 | result_text = result[0] if result else "" |
| 331 | elif isinstance(result, str): |
| 332 | result_text = result |
| 333 | else: |
| 334 | result_text = str(result) |
| 335 | |
| 336 | # 尝试提取 JSON |
| 337 | import json |
| 338 | try: |
| 339 | # 找到 JSON 部分 |
| 340 | import re |
| 341 | json_match = re.search(r'\{[^{}]*\}', result_text, re.DOTALL) |
| 342 | if json_match: |
| 343 | eval_result = json.loads(json_match.group()) |
| 344 | return eval_result |
| 345 | except: |
| 346 | pass |
| 347 | |
| 348 | return {"score": 5, "issues": ["评估解析失败"], "is_acceptable": True} |
| 349 | |
| 350 | except Exception as e: |
| 351 | logger.warning(f"VLM evaluation failed: {e}") |
| 352 | return {"score": 5, "issues": [str(e)], "is_acceptable": True} |
| 353 | |
| 354 | def _select_best_with_vlm(self, image_paths: List[str], name: str, description: str, |
| 355 | asset_type: str, species: str = "", vlm_model: str = "qwen3.5-plus") -> tuple: |
| 356 | """使用 VLM 从多个版本中选择最好的一张""" |
| 357 | from models.vlm_client import VLM |
| 358 | import re |
| 359 | |
| 360 | if not image_paths: |
| 361 | return None, None |
| 362 | |
| 363 | try: |
| 364 | vlm = VLM() |
| 365 | |
| 366 | # 选择评估提示词 |
| 367 | if asset_type == 'characters': |
| 368 | select_prompt = load_prompt('character', 'eval_select_best', 'zh').format( |
| 369 | num_images=len(image_paths), |
| 370 | num_images_minus_1=len(image_paths) - 1, |
| 371 | character_name=name, |
| 372 | character_description=description, |
| 373 | species=species, |
| 374 | images_list="\n".join([f"图片{i}: {p}" for i, p in enumerate(image_paths)]) |
| 375 | ) |
| 376 | else: |
| 377 | select_prompt = load_prompt('setting', 'eval_select_best', 'zh').format( |
| 378 | num_images=len(image_paths), |
| 379 | num_images_minus_1=len(image_paths) - 1, |
| 380 | setting_name=name, |
| 381 | setting_description=description, |
| 382 | images_list="\n".join([f"图片{i}: {p}" for i, p in enumerate(image_paths)]) |
| 383 | ) |
| 384 | |
| 385 | result = vlm.query(select_prompt, image_paths=image_paths, model=vlm_model) |
| 386 | logger.info(f"[{asset_type}] {name} VLM selection result: {result}") |
| 387 | |
| 388 | # 解析 JSON 结果 |
| 389 | if result and isinstance(result, list): |
| 390 | result_text = result[0] if result else "" |
| 391 | elif isinstance(result, str): |
| 392 | result_text = result |
| 393 | else: |
| 394 | result_text = str(result) |
| 395 | |
| 396 | logger.info(f"[{asset_type}] {name} VLM selection raw response: {result_text[:500]}") |
| 397 | |
| 398 | # 解析 JSON,提取 best_index |
| 399 | best_index = 0 |
| 400 | try: |
| 401 | # 找到 JSON 开始和结束 |
| 402 | json_start = result_text.find('{') |
| 403 | json_end = result_text.rfind('}') + 1 |
| 404 | if json_start >= 0 and json_end > json_start: |
| 405 | json_str = result_text[json_start:json_end] |
| 406 | selection_result = json.loads(json_str) |
| 407 | best_index = selection_result.get('best_index', 0) |
| 408 | logger.info(f"[{asset_type}] {name} Parsed best_index: {best_index}") |
| 409 | except Exception as e: |
| 410 | logger.warning(f"[{asset_type}] {name} JSON parse failed: {e}, using last image") |
| 411 | best_index = len(image_paths) - 1 |
| 412 | |
| 413 | # 如果找到了 best_index,选择对应的图片 |
| 414 | if best_index is not None and 0 <= best_index < len(image_paths): |
| 415 | best_path = image_paths[best_index] |
| 416 | best_eval = {"score": 8, "issues": [], "is_acceptable": True, "reason": f"VLM selected image {best_index + 1} of {len(image_paths)}"} |
| 417 | logger.info(f"[{asset_type}] {name} Selected image: {best_path}") |
| 418 | return best_path, best_eval |
| 419 | else: |
| 420 | logger.warning(f"[{asset_type}] {name} Invalid best_index: {best_index}, available images: {len(image_paths)}") |
| 421 | |
| 422 | except Exception as e: |
| 423 | logger.warning(f"VLM selection failed: {e}") |
| 424 | |
| 425 | return None, None |
| 426 | |
| 427 | # ─── 从剧本JSON读取角色/场景数据 ─── |
| 428 | |
| 429 | def _read_script_data(self, input_data: Dict) -> dict: |
| 430 | """从编排器注入的 script_generation artifact 提取角色和场景数据。""" |
| 431 | script_gen = self._session_artifact(input_data, "script_generation") |
| 432 | chars = {} |
| 433 | for c in script_gen.get("characters", []): |
| 434 | cid = c.get("character_id") or c.get("id") or "" |
| 435 | if cid: |
| 436 | chars[cid] = { |
| 437 | "name": c.get("name", ""), |
| 438 | "description": c.get("description", ""), |
| 439 | "species": c.get("species", ""), |
| 440 | } |
| 441 | |
| 442 | sets = {} |
| 443 | for s in script_gen.get("settings", []): |
| 444 | sid_val = s.get("setting_id") or s.get("id") or "" |
| 445 | if sid_val: |
| 446 | sets[sid_val] = { |
| 447 | "name": s.get("name", ""), |
| 448 | "description": s.get("description", ""), |
| 449 | } |
| 450 | |
| 451 | return {"characters": chars, "settings": sets} |
| 452 | |
| 453 | # ─── 核心流程 ─── |
| 454 | |
| 455 | async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict: |
| 456 | from config import settings |
| 457 | from models.image_client import ImageClient |
| 458 | |
| 459 | sid = input_data["session_id"] |
| 460 | style = input_data.get("style", "anime") |
| 461 | t2i_model = self._require_input(input_data, "image_t2i_model") |
| 462 | vlm_model = self._require_input(input_data, "vlm_model") |
| 463 | llm_model = input_data.get("llm_model") or self._session_meta(input_data).get("llm_model") or "" |
| 464 | # 根据 enable_concurrency 决定并发数 |
| 465 | enable_concurrency = input_data.get("enable_concurrency", True) |
| 466 | logger.info(f"[CharacterAgent] enable_concurrency={enable_concurrency}") |
| 467 | from models.config_model import get_max_concurrency |
| 468 | max_concurrency = get_max_concurrency(t2i_model, enable_concurrency) |
| 469 | logger.info(f"[CharacterAgent] 使用并发数={max_concurrency}") |
| 470 | concurrency = max_concurrency |
| 471 | |
| 472 | img_client = ImageClient( |
| 473 | dashscope_api_key=settings.DASHSCOPE_API_KEY, |
| 474 | dashscope_base_url=settings.DASHSCOPE_BASE_URL, |
| 475 | gpt_api_key=settings.OPENAI_API_KEY, |
| 476 | gpt_base_url=settings.OPENAI_BASE_URL, |
| 477 | proxy=settings.provider_proxy("openai"), |
| 478 | ark_api_key=settings.ARK_API_KEY, |
| 479 | ark_base_url=settings.ARK_BASE_URL, |
| 480 | ) |
| 481 | rewrite_results = self._collect_rewrite_results(self._session_artifact(input_data, "character_design")) |
| 482 | |
| 483 | # ═══════════ 介入: 重新生成指定素材 ═══════════ |
| 484 | if intervention: |
| 485 | regen_chars = intervention.get("regenerate_characters", []) # list of asset_id |
| 486 | regen_sets = intervention.get("regenerate_settings", []) # list of asset_id |
| 487 | select_chars = intervention.get("select_characters", {}) # {asset_id: path} |
| 488 | select_sets = intervention.get("select_settings", {}) # {asset_id: path} |
| 489 | update_descriptions = intervention.get("update_descriptions", {}) # {characters: {}, settings: {}} |
| 490 | |
| 491 | # 优先从 input_data (artifacts) 中恢复当前状态,确保之前在界面上做的修改能保留 |
| 492 | chars_desc = {} |
| 493 | sets_desc = {} |
| 494 | |
| 495 | # input_data.get("characters") 在正常流程中是 list,需转换为 dict 以便后续 .items() 使用 |
| 496 | input_chars = input_data.get("characters", []) |
| 497 | if isinstance(input_chars, list): |
| 498 | for c in input_chars: |
| 499 | if isinstance(c, dict) and "id" in c: |
| 500 | chars_desc[c["id"]] = { |
| 501 | "name": c.get("name", ""), |
| 502 | "description": c.get("description", ""), |
| 503 | "species": c.get("species", ""), |
| 504 | } |
| 505 | elif isinstance(input_chars, dict): |
| 506 | chars_desc = input_chars |
| 507 | |
| 508 | input_sets = input_data.get("settings", []) |
| 509 | if isinstance(input_sets, list): |
| 510 | for s in input_sets: |
| 511 | if isinstance(s, dict) and "id" in s: |
| 512 | sets_desc[s["id"]] = { |
| 513 | "name": s.get("name", ""), |
| 514 | "description": s.get("description", ""), |
| 515 | } |
| 516 | elif isinstance(input_sets, dict): |
| 517 | sets_desc = input_sets |
| 518 | |
| 519 | # 如果 input_data 为空(比如后端重启后的第一次介入),再读原始文件 |
| 520 | if not chars_desc and not sets_desc: |
| 521 | script_data = self._read_script_data(input_data) |
| 522 | chars_desc = script_data["characters"] |
| 523 | sets_desc = script_data["settings"] |
| 524 | |
| 525 | # 处理描述更新 |
| 526 | if update_descriptions: |
| 527 | updated_chars = update_descriptions.get("characters", {}) |
| 528 | updated_sets = update_descriptions.get("settings", {}) |
| 529 | |
| 530 | # 更新角色描述 (支持两种格式:字符串或字典) |
| 531 | for asset_id, info in updated_chars.items(): |
| 532 | if asset_id in chars_desc: |
| 533 | if isinstance(info, dict): |
| 534 | if "name" in info: |
| 535 | chars_desc[asset_id]["name"] = info["name"] |
| 536 | if "description" in info: |
| 537 | chars_desc[asset_id]["description"] = info["description"] |
| 538 | if "species" in info: |
| 539 | chars_desc[asset_id]["species"] = info["species"] |
| 540 | else: |
| 541 | # 简单字符串格式:直接更新 description |
| 542 | chars_desc[asset_id]["description"] = str(info) |
| 543 | |
| 544 | # 更新场景描述 (支持两种格式:字符串或字典) |
| 545 | for asset_id, info in updated_sets.items(): |
| 546 | if asset_id in sets_desc: |
| 547 | if isinstance(info, dict): |
| 548 | if "name" in info: |
| 549 | sets_desc[asset_id]["name"] = info["name"] |
| 550 | if "description" in info: |
| 551 | sets_desc[asset_id]["description"] = info["description"] |
| 552 | else: |
| 553 | # 简单字符串格式:直接更新 description |
| 554 | sets_desc[asset_id]["description"] = str(info) |
| 555 | |
| 556 | logger.info(f"[CharacterAgent] Updated descriptions for session {sid}") |
| 557 | |
| 558 | if regen_chars or regen_sets: |
| 559 | self._report_progress("角色设计", "重新生成中...", 10) |
| 560 | tasks = [] |
| 561 | for asset_id in regen_chars: |
| 562 | info = chars_desc.get(asset_id, {}) |
| 563 | tasks.append(("characters", asset_id, info.get("name", ""), info.get("description", ""), info.get("species", ""))) |
| 564 | for asset_id in regen_sets: |
| 565 | info = sets_desc.get(asset_id, {}) |
| 566 | tasks.append(("settings", asset_id, info.get("name", ""), info.get("description", ""), "")) |
| 567 | |
| 568 | def regen_run(): |
| 569 | total = len(tasks) |
| 570 | done = 0 |
| 571 | with ThreadPoolExecutor(max_workers=concurrency) as executor: |
| 572 | futs = {} |
| 573 | for atype, aid, name, desc, species in tasks: |
| 574 | existing_versions = self._list_versions(sid, atype, aid) |
| 575 | self._report_progress("角色设计", f"正在生成: {name}", 10, data={ |
| 576 | "asset_complete": { |
| 577 | "type": atype, |
| 578 | "id": aid, |
| 579 | "status": "running", |
| 580 | "versions": existing_versions, |
| 581 | } |
| 582 | }) |
| 583 | fut = executor.submit( |
| 584 | self._generate_one, img_client, |
| 585 | aid, name, desc, atype, style, species, t2i_model, vlm_model, sid, llm_model |
| 586 | ) |
| 587 | futs[fut] = (atype, aid, name) |
| 588 | for fut in as_completed(futs): |
| 589 | atype, aid, fname = futs[fut] |
| 590 | _, result_path, eval_result, rewrite_result = fut.result() |
| 591 | done += 1 |
| 592 | pct = 10 + int(85 * done / max(total, 1)) |
| 593 | if rewrite_result: |
| 594 | rewrite_results[atype][aid] = rewrite_result |
| 595 | if result_path: |
| 596 | versions = self._list_versions(sid, atype, aid) |
| 597 | asset_complete = { |
| 598 | "type": atype, "id": aid, "status": "done", |
| 599 | "selected": result_path, "versions": versions, |
| 600 | "evaluation": eval_result, |
| 601 | } |
| 602 | if rewrite_result: |
| 603 | asset_complete["rewrite_result"] = rewrite_result |
| 604 | self._report_progress("角色设计", f"完成: {fname}", pct, data={ |
| 605 | "asset_complete": asset_complete |
| 606 | }) |
| 607 | else: |
| 608 | asset_complete = { |
| 609 | "type": atype, "id": aid, "status": "failed", |
| 610 | "selected": "", "versions": [], |
| 611 | } |
| 612 | if rewrite_result: |
| 613 | asset_complete["rewrite_result"] = rewrite_result |
| 614 | self._report_progress("角色设计", f"失败: {fname}", pct, data={ |
| 615 | "asset_complete": asset_complete |
| 616 | }) |
| 617 | |
| 618 | loop = asyncio.get_running_loop() |
| 619 | await loop.run_in_executor(None, regen_run) |
| 620 | |
| 621 | self._report_progress("角色设计", "完成", 100) |
| 622 | return self._build_payload(sid, chars_desc, sets_desc, select_chars, select_sets, rewrite_results) |
| 623 | |
| 624 | # ═══════════ 正常流程: 全量首次生成 ═══════════ |
| 625 | self._report_progress("角色设计", "读取剧本数据...", 5) |
| 626 | |
| 627 | # 优先从 input_data 获取素材定义,如果没有再从原始剧本文件读取 |
| 628 | chars_desc, sets_desc = {}, {} |
| 629 | if input_data and isinstance(input_data, dict): |
| 630 | if "characters" in input_data: |
| 631 | chars_desc = input_data["characters"] |
| 632 | if "settings" in input_data: |
| 633 | sets_desc = input_data["settings"] |
| 634 | |
| 635 | if not chars_desc and not sets_desc: |
| 636 | script_data = self._read_script_data(input_data) |
| 637 | chars_desc = script_data["characters"] |
| 638 | sets_desc = script_data["settings"] |
| 639 | |
| 640 | if not chars_desc and not sets_desc: |
| 641 | raise Exception("未能从剧本中读取到角色或场景描述数据") |
| 642 | |
| 643 | # 发送素材预览(含所有素材和当前状态) |
| 644 | preview = self._build_preview(sid, chars_desc, sets_desc) |
| 645 | self._report_progress("角色设计", "加载素材列表", 8, data={"assets_preview": preview}) |
| 646 | |
| 647 | def run(): |
| 648 | all_tasks = [] |
| 649 | for asset_id, info in chars_desc.items(): |
| 650 | existing = self._list_versions(sid, 'characters', asset_id) |
| 651 | if existing: |
| 652 | continue |
| 653 | all_tasks.append(("characters", asset_id, info.get("name", ""), info.get("description", ""), info.get("species", ""))) |
| 654 | |
| 655 | for asset_id, info in sets_desc.items(): |
| 656 | desc = info.get("description", "") if isinstance(info, dict) else info |
| 657 | existing = self._list_versions(sid, 'settings', asset_id) |
| 658 | if existing: |
| 659 | continue |
| 660 | all_tasks.append(("settings", asset_id, info.get("name", "") if isinstance(info, dict) else "", desc, "")) |
| 661 | |
| 662 | if not all_tasks: |
| 663 | self._report_progress("角色设计", "所有素材已存在", 95) |
| 664 | return |
| 665 | |
| 666 | total = len(all_tasks) |
| 667 | done = 0 |
| 668 | |
| 669 | with ThreadPoolExecutor(max_workers=concurrency) as executor: |
| 670 | futs = {} |
| 671 | for atype, aid, name, desc, species in all_tasks: |
| 672 | existing_versions = self._list_versions(sid, atype, aid) |
| 673 | self._report_progress("角色设计", f"正在生成: {name}", 10, data={ |
| 674 | "asset_complete": { |
| 675 | "type": atype, |
| 676 | "id": aid, |
| 677 | "status": "running", |
| 678 | "versions": existing_versions, |
| 679 | } |
| 680 | }) |
| 681 | fut = executor.submit( |
| 682 | self._generate_one, img_client, |
| 683 | aid, name, desc, atype, style, species, t2i_model, vlm_model, sid, llm_model, |
| 684 | ) |
| 685 | futs[fut] = (atype, aid, name) |
| 686 | |
| 687 | for fut in as_completed(futs): |
| 688 | atype, aid, fname = futs[fut] |
| 689 | _, result_path, _, rewrite_result = fut.result() |
| 690 | done += 1 |
| 691 | pct = 10 + int(85 * done / max(total, 1)) |
| 692 | if rewrite_result: |
| 693 | rewrite_results[atype][aid] = rewrite_result |
| 694 | if result_path: |
| 695 | versions = self._list_versions(sid, atype, aid) |
| 696 | asset_complete = { |
| 697 | "type": atype, "id": aid, "status": "done", |
| 698 | "selected": result_path, "versions": versions, |
| 699 | } |
| 700 | if rewrite_result: |
| 701 | asset_complete["rewrite_result"] = rewrite_result |
| 702 | self._report_progress("角色设计", f"完成: {fname}", pct, data={ |
| 703 | "asset_complete": asset_complete |
| 704 | }) |
| 705 | else: |
| 706 | asset_complete = { |
| 707 | "type": atype, "id": aid, "status": "failed", |
| 708 | "selected": "", "versions": self._list_versions(sid, atype, aid), |
| 709 | } |
| 710 | if rewrite_result: |
| 711 | asset_complete["rewrite_result"] = rewrite_result |
| 712 | self._report_progress("角色设计", f"失败: {fname}", pct, data={ |
| 713 | "asset_complete": asset_complete |
| 714 | }) |
| 715 | |
| 716 | self._report_progress("角色设计", "完成", 100) |
| 717 | |
| 718 | loop = asyncio.get_running_loop() |
| 719 | await loop.run_in_executor(None, run) |
| 720 | |
| 721 | return self._build_payload(sid, chars_desc, sets_desc, rewrite_results=rewrite_results) |
| 722 | |
| 723 | def _build_payload(self, sid: str, chars_desc: dict, sets_desc: dict, |
| 724 | selected_chars: dict = None, selected_sets: dict = None, |
| 725 | rewrite_results: dict = None) -> dict: |
| 726 | """构建返回给前端的 payload""" |
| 727 | selected_chars = selected_chars or {} |
| 728 | selected_sets = selected_sets or {} |
| 729 | rewrite_results = rewrite_results or {"characters": {}, "settings": {}} |
| 730 | |
| 731 | characters = [] |
| 732 | for asset_id, info in chars_desc.items(): |
| 733 | desc = info.get("description", "") if isinstance(info, dict) else info |
| 734 | name = info.get("name", "") if isinstance(info, dict) else "" |
| 735 | sel = selected_chars.get(asset_id, "") |
| 736 | item = self._build_asset_info(sid, 'characters', asset_id, name, desc, sel) |
| 737 | if rewrite_results.get("characters", {}).get(asset_id): |
| 738 | item["rewrite_result"] = rewrite_results["characters"][asset_id] |
| 739 | characters.append(item) |
| 740 | |
| 741 | settings_list = [] |
| 742 | for asset_id, info in sets_desc.items(): |
| 743 | desc = info.get("description", "") if isinstance(info, dict) else info |
| 744 | name = info.get("name", "") if isinstance(info, dict) else "" |
| 745 | sel = selected_sets.get(asset_id, "") |
| 746 | item = self._build_asset_info(sid, 'settings', asset_id, name, desc, sel) |
| 747 | if rewrite_results.get("settings", {}).get(asset_id): |
| 748 | item["rewrite_result"] = rewrite_results["settings"][asset_id] |
| 749 | settings_list.append(item) |
| 750 | |
| 751 | # 图片生成完成即为阶段完成,用户选择图片只是更新数据 |
| 752 | return { |
| 753 | "payload": { |
| 754 | "session_id": sid, |
| 755 | "characters": characters, |
| 756 | "settings": settings_list, |
| 757 | }, |
| 758 | "stage_completed": True, |
| 759 | } |
| 760 |