返回 VideoClaw
character_agent.py
根目录 / video-claw / video-claw / backend / core / agents / character_agent.py
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
760 lines PYTHON