返回 VideoClaw
video_agent.py
根目录 / video-claw / video-claw / backend / core / agents / video_agent.py
1 # -*- coding: utf-8 -*-
2 """
3 阶段5: 视频生成智能体
4 - 从编排器注入的 artifacts["storyboard"] 读取拍摄片段(Segments)
5 - 视频提示词:风格控制 + 人物列表 + 分镜列表(分镜1: [时长] content...)
6 - 参考图:从 artifacts["reference_generation"] 读取
7 - 支持逐项并发生成、实时预览、重新生成
8 """
9
10 import os
11 import re
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
20 logger = logging.getLogger(__name__)
21
22
23 class VideoDirectorAgent(AgentInterface):
24 """视频生成:拍摄片段(Segments) → 组装提示词 → 视频片段"""
25
26 def __init__(self):
27 super().__init__(name="VideoDirector")
28
29 # ─── 版本管理 ───
30
31 @staticmethod
32 def _video_base(sid: str) -> str:
33 return os.path.join('code/result/video', str(sid))
34
35 def _list_versions(self, sid: str, segment_id: str) -> List[str]:
36 """列出某个片段视频的所有历史版本"""
37 video_dir = self._video_base(sid)
38 pattern = os.path.join(video_dir, f"{segment_id}*.mp4")
39 files = [f for f in sorted(glob.glob(pattern), key=os.path.getmtime)
40 if not f.endswith('_final.mp4')]
41 return files
42
43 def _next_version_path(self, sid: str, segment_id: str) -> str:
44 """获取下一个版本路径"""
45 video_dir = self._video_base(sid)
46 os.makedirs(video_dir, exist_ok=True)
47
48 existing = self._list_versions(sid, segment_id)
49 if not existing:
50 return os.path.join(video_dir, f"{segment_id}.mp4")
51
52 max_v = 1
53 for fp in existing:
54 bn = os.path.splitext(os.path.basename(fp))[0]
55 m = re.search(r'_v(\d+)$', bn)
56 if m:
57 max_v = max(max_v, int(m.group(1)))
58
59 return os.path.join(video_dir, f"{segment_id}_v{max_v + 1}.mp4")
60
61 # ─── 视频生成 ───
62
63 def _generate_video_with_doctor(self, client, *, prompt: str, model: str,
64 llm_model: str, context: dict, **kwargs) -> tuple:
65 """Generate once; retry with a doctor-rewrite prompt only for prompt issues."""
66 try:
67 client.generate_video(prompt=prompt, model=model, **kwargs)
68 return prompt, None, None
69 except Exception as exc:
70 from .doctor_agent import DoctorAgent
71
72 doctor = DoctorAgent(llm_model=llm_model)
73 rewrite, diagnosis, rewrite_result = doctor.maybe_rewrite_prompt(
74 stage="video_generation",
75 model=model,
76 prompt=prompt,
77 error=str(exc),
78 context=context,
79 )
80 if not rewrite:
81 logger.info("Doctor skipped video prompt rewrite: %s", diagnosis.get("reason"))
82 raise
83
84 logger.info("Doctor rewrote video prompt: reason_type=%s reason=%s",
85 diagnosis.get("reason_type"), diagnosis.get("reason"))
86 client.generate_video(prompt=rewrite, model=model, **kwargs)
87 return rewrite, diagnosis, rewrite_result
88
89 def _generate_one(self, sid: str, segment_id: str, prompt: str,
90 img_path: Optional[str], video_model: str,
91 duration: int = 10, sound: str = "",
92 shot_type: str = "multi",
93 video_ratio: str = "16:9",
94 video_resolution: str = "720P",
95 video_generation_mode: str = "first_frame",
96 last_image_path: Optional[str] = None,
97 reference_image_paths: Optional[List[str]] = None,
98 llm_model: str = "") -> tuple:
99 """生成单个视频片段,返回 (segment_id, path_or_None, rewrite_result_or_None)。"""
100 if self.cancellation_check and self.cancellation_check():
101 logger.info(f"VideoDirectorAgent: {segment_id} 跳过(用户取消)")
102 return segment_id, None, None
103
104 reference_image_paths = reference_image_paths or []
105 if video_generation_mode == "reference":
106 missing_refs = [path for path in reference_image_paths if not os.path.exists(path)]
107 if not reference_image_paths or missing_refs:
108 logger.warning("Reference images missing for %s: %s", segment_id, missing_refs or reference_image_paths)
109 return segment_id, None, None
110 elif not img_path or not os.path.exists(img_path):
111 logger.warning(f"Image missing for {segment_id}: {img_path}")
112 return segment_id, None, None
113
114 save_path = self._next_version_path(sid, segment_id)
115 try:
116 from models.video_client import VideoClient
117 client = VideoClient()
118 _, _, rewrite_result = self._generate_video_with_doctor(
119 client,
120 prompt=prompt,
121 model=video_model,
122 llm_model=llm_model,
123 context={
124 "segment_id": segment_id,
125 "video_generation_mode": video_generation_mode,
126 "duration": duration,
127 "video_ratio": video_ratio,
128 "video_resolution": video_resolution,
129 },
130 image_path=img_path,
131 save_path=save_path,
132 duration=duration,
133 sound=sound,
134 shot_type=shot_type,
135 video_ratio=video_ratio,
136 resolution=video_resolution,
137 last_image_path=last_image_path if video_generation_mode == "start_end_frame" else None,
138 reference_image_paths=reference_image_paths if video_generation_mode == "reference" else None,
139 )
140 return segment_id, save_path, rewrite_result
141 except Exception as e:
142 logger.error(f"Video gen failed for {segment_id}: {e}")
143 if os.path.exists(save_path):
144 try:
145 os.remove(save_path)
146 except Exception:
147 pass
148 return segment_id, None, None
149
150 # ─── 提示词组装 ───
151
152 def _format_shot_section(self, segment: dict) -> str:
153 prompt = "分镜列表:"
154 shots = segment.get("shots", [])
155 for i, shot in enumerate(shots, 1):
156 dur = shot.get("duration", 5)
157 content = shot.get("content", "").strip()
158 prompt += f"\n分镜{i}:[{dur}秒] {content}"
159 return prompt
160
161 @staticmethod
162 def _extract_shot_section(prompt: str) -> str:
163 text = (prompt or "").strip()
164 if not text:
165 return "分镜列表:"
166 marker = "分镜列表:"
167 alt_marker = "分镜列表:"
168 if marker in text:
169 text = text[text.index(marker):].strip()
170 elif alt_marker in text:
171 text = marker + text[text.index(alt_marker) + len(alt_marker):].strip()
172 else:
173 text = f"{marker}\n{text}"
174 no_caption_marker = "\n不要生成字幕或水印"
175 if no_caption_marker in text:
176 text = text[:text.index(no_caption_marker)].rstrip()
177 return text
178
179 def _display_prompt(self, segment: dict, video_data: Optional[dict] = None) -> str:
180 """前端只展示可编辑的分镜列表;风格和人物信息在调用视频模型前即时拼接。"""
181 if video_data and video_data.get("description"):
182 return self._extract_shot_section(video_data["description"])
183 return self._format_shot_section(segment)
184
185 def _build_character_section(self, segment: dict, character_artifact: Optional[dict]) -> str:
186 characters = character_artifact.get("characters", []) if isinstance(character_artifact, dict) else []
187 if not characters:
188 return "人物列表:无"
189
190 character_map = self._build_name_asset_map(characters)
191 selected_assets = []
192 seen = set()
193
194 for character_name in segment.get("characters") or []:
195 asset = self._match_asset_by_name(str(character_name), characters, character_map)
196 asset_key = str(asset.get("id") or asset.get("name") or "") if asset else ""
197 if asset and asset_key not in seen:
198 selected_assets.append(asset)
199 seen.add(asset_key)
200
201 lines = []
202 for asset in selected_assets:
203 name = str(asset.get("name") or asset.get("id") or "").strip()
204 desc = str(asset.get("description") or "").strip()
205 if name and desc:
206 lines.append(f"- {name}:{desc}")
207 elif name:
208 lines.append(f"- {name}")
209
210 return "人物列表:\n" + "\n".join(lines) if lines else "人物列表:无"
211
212 def _assemble_prompt(self, segment: dict, style_prompt: str,
213 character_artifact: Optional[dict] = None,
214 video_data: Optional[dict] = None) -> str:
215 """组装视频提示词
216 格式:
217 风格控制:用户选择的风格提示词, 电影质感
218 人物列表:从第二阶段人物描述读取
219 分镜列表:分镜1:[时长] content... 分镜2:[时长] content...
220 """
221 if video_data and "description" in video_data:
222 # 前端修改后的提示词会存入 artifacts.video_generation.clips.description。
223 # 兼容旧格式:保留用户修改过的分镜部分,重新补齐最新风格和人物列表。
224 shot_section = self._extract_shot_section(video_data["description"])
225 else:
226 shot_section = self._format_shot_section(segment)
227
228 return (
229 f"风格控制:{style_prompt}\n"
230 f"{self._build_character_section(segment, character_artifact)}\n"
231 f"{shot_section}\n"
232 "不要生成字幕或水印"
233 )
234
235 def _get_style_keywords(self, session_data: dict) -> str:
236 """从会话数据获取风格关键词"""
237 style = session_data.get('style', 'realistic').lower()
238
239 STYLE_MAP = {
240 "anime": "anime style, vibrant colors, clean lines,",
241 "realistic": "photorealistic, cinematic lighting, high-detail textures,",
242 "cartoon": "cartoon style, thick outlines, bold colors,",
243 "3d-disney": "3D CGI animation, Disney/Pixar style, smooth textures,",
244 "oil-painting": "oil painting, artistic brushstrokes, rich textures,",
245 "chinese-ink": "Chinese ink wash painting, traditional style, soft strokes,"
246 }
247 return STYLE_MAP.get(style, "cinematic, high quality,")
248
249 # ─── 参考图获取 ───
250
251 def _get_reference_image(self, sid: str, segment_id: str, scene_map: dict) -> str:
252 """获取参考图路径:优先用选中的版本,次之用最新版本"""
253 # 1. 检查 session 中 artifacts.reference_generation.scenes 里的 selected
254 if segment_id in scene_map and scene_map[segment_id].get("selected"):
255 path = scene_map[segment_id]["selected"]
256 if os.path.exists(path):
257 return path
258
259 # 2. 回退:扫描磁盘 Scenes 目录
260 from .reference_agent import ReferenceGeneratorAgent
261 versions = ReferenceGeneratorAgent._list_versions_static(sid, segment_id)
262 if versions:
263 return versions[-1]
264
265 # 3. 默认路径
266 return os.path.abspath(os.path.join('code/result/image', str(sid), 'Scenes', f"{segment_id}.jpg"))
267
268 def _get_next_reference_image(self, sid: str, segment_index: int, segments: list, scene_map: dict) -> Optional[str]:
269 """首尾帧模式下,优先用下一个片段参考图作为尾帧。"""
270 if segment_index + 1 >= len(segments):
271 return None
272 next_segment_id = segments[segment_index + 1].get("segment_id")
273 if not next_segment_id:
274 return None
275 path = self._get_reference_image(sid, next_segment_id, scene_map)
276 return path if path and os.path.exists(path) else None
277
278 @staticmethod
279 def _asset_selected_path(asset: dict) -> str:
280 selected = asset.get("selected") or ""
281 if selected and os.path.exists(selected):
282 return selected
283 # Legacy session compatibility: some old artifacts only have versions and no selected field.
284 for path in reversed(asset.get("versions") or []):
285 if path and os.path.exists(path):
286 return path
287 return ""
288
289 @staticmethod
290 def _build_name_asset_map(assets: list[dict]) -> dict[str, dict]:
291 """Build name -> asset mapping from character_design characters/settings."""
292 mapping = {}
293 for asset in assets:
294 name = str(asset.get("name") or "").strip()
295 asset_id = str(asset.get("id") or "").strip()
296 if name:
297 mapping[name] = asset
298 if asset_id:
299 mapping[asset_id] = asset
300 return mapping
301
302 @staticmethod
303 def _match_asset_by_name(name: str, assets: list[dict], asset_map: Optional[dict[str, dict]] = None) -> Optional[dict]:
304 clean_name = (name or "").strip()
305 if not clean_name:
306 return None
307 if asset_map and clean_name in asset_map:
308 return asset_map[clean_name]
309 for asset in assets:
310 asset_name = str(asset.get("name") or "").strip()
311 if asset_name and asset_name == clean_name:
312 return asset
313 for asset in assets:
314 asset_name = str(asset.get("name") or "").strip()
315 if asset_name and (asset_name in clean_name or clean_name in asset_name):
316 return asset
317 return None
318
319 def _get_segment_reference_assets(self, segment: dict, character_artifact: dict) -> List[str]:
320 """参考图生视频:按 segment.characters/location 读取第二阶段用户选中的人物图和场景图。"""
321 characters = character_artifact.get("characters", []) if isinstance(character_artifact, dict) else []
322 settings = character_artifact.get("settings", []) if isinstance(character_artifact, dict) else []
323 character_map = self._build_name_asset_map(characters)
324 setting_map = self._build_name_asset_map(settings)
325 reference_paths: List[str] = []
326 seen = set()
327
328 def add_asset(asset: Optional[dict]) -> None:
329 if not asset:
330 return
331 path = self._asset_selected_path(asset)
332 if path and path not in seen:
333 reference_paths.append(path)
334 seen.add(path)
335
336 location = str(segment.get("location") or "").strip()
337 add_asset(self._match_asset_by_name(location, settings, setting_map))
338
339 for character_name in segment.get("characters") or []:
340 add_asset(self._match_asset_by_name(str(character_name), characters, character_map))
341
342 return reference_paths
343
344 @staticmethod
345 def _select_video_model(input_data: dict, session_meta: dict) -> tuple[str, str]:
346 mode = (
347 input_data.get("video_generation_mode")
348 or session_meta.get("video_generation_mode")
349 or "first_frame"
350 )
351 model_key = {
352 "first_frame": "video_first_frame_model",
353 "start_end_frame": "video_start_end_model",
354 "reference": "video_reference_model",
355 }.get(mode, "video_first_frame_model")
356 model = input_data.get(model_key) or session_meta.get(model_key)
357 if not model:
358 # Legacy session compatibility: sessions created before mode-specific video models only have video_model.
359 model = input_data.get("video_model") or session_meta.get("video_model")
360 if not model:
361 raise ValueError("Missing required model configuration: video_model")
362 return mode, model
363
364 # ─── 预览 / Payload ───
365
366 @staticmethod
367 def _collect_rewrite_results(video_clips: Optional[list]) -> dict:
368 results = {}
369 for clip in video_clips or []:
370 if isinstance(clip, dict) and clip.get("id") and clip.get("rewrite_result"):
371 results[clip["id"]] = clip["rewrite_result"]
372 return results
373
374 def _build_preview(self, sid: str, segments: list, scene_map: dict,
375 video_clips: Optional[list] = None) -> list:
376 preview = []
377 clip_map = {c.get("id"): c for c in (video_clips or []) if isinstance(c, dict) and c.get("id")}
378 existing_rewrite_results = self._collect_rewrite_results(video_clips)
379 for idx, seg in enumerate(segments, 1):
380 segment_id = seg["segment_id"]
381 versions = self._list_versions(sid, segment_id)
382 ep_n = seg.get('episode_number', 1)
383 seg_n = seg.get('segment_number', idx)
384 item = {
385 "id": segment_id,
386 "name": f"第{ep_n}集-片段{seg_n}",
387 "episode": ep_n,
388 "index": seg_n,
389 "description": self._display_prompt(seg, clip_map.get(segment_id)),
390 "duration": seg.get('total_duration', 10),
391 "selected": versions[-1] if versions else "",
392 "versions": versions,
393 "status": "done" if versions else "pending",
394 }
395 if existing_rewrite_results.get(segment_id):
396 item["rewrite_result"] = existing_rewrite_results[segment_id]
397 preview.append(item)
398 return preview
399
400 def _build_payload(self, sid: str, segments: list, video_clips: Optional[list] = None,
401 rewrite_results: Optional[dict] = None) -> dict:
402 clips = []
403 clip_map = {c.get("id"): c for c in (video_clips or []) if isinstance(c, dict) and c.get("id")}
404 existing_rewrite_results = self._collect_rewrite_results(video_clips)
405 rewrite_results = rewrite_results or {}
406 for idx, seg in enumerate(segments, 1):
407 segment_id = seg["segment_id"]
408 versions = self._list_versions(sid, segment_id)
409 ep_n = seg.get('episode_number', 1)
410 seg_n = seg.get('segment_number', idx)
411 item = {
412 "id": segment_id,
413 "name": f"第{ep_n}集-片段{seg_n}",
414 "episode": ep_n,
415 "index": seg_n,
416 "description": self._display_prompt(seg, clip_map.get(segment_id)),
417 "duration": seg.get('total_duration', 10),
418 "selected": versions[-1] if versions else "",
419 "versions": versions,
420 "status": "done" if versions else "failed",
421 }
422 rewrite_result = rewrite_results.get(segment_id) or existing_rewrite_results.get(segment_id)
423 if rewrite_result:
424 item["rewrite_result"] = rewrite_result
425 clips.append(item)
426 return {
427 "payload": {
428 "session_id": sid,
429 "clips": clips,
430 },
431 "stage_completed": True,
432 }
433
434 def _update_session_video_data(self, sid: str, segments: list, style_prompt: str) -> None:
435 """保留兼容入口;session 状态统一由 WorkflowEngine 持久化。"""
436 return
437
438 # ─── 核心流程 ───
439
440 async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
441 from config import settings
442
443 input_data = self._merge_session_params(input_data)
444 sid = input_data["session_id"]
445
446 # ═══ 介入:用户选择指定版本 ═══
447 if intervention and "selected_clips" in intervention:
448 selected_clips = intervention["selected_clips"] # Dict[segment_id, path]
449 logger.info(f"[VideoAgent] 用户更新片段选择: {selected_clips}")
450
451 # 返回当前状态
452 artifacts = self._session_artifacts(input_data)
453 episodes = artifacts.get('storyboard', {}).get('episodes', [])
454 segments = []
455 for ep in episodes:
456 segments.extend(ep.get("segments", []))
457 video_clips = artifacts.get('video_generation', {}).get('clips', [])
458 payload = self._build_payload(sid, segments, video_clips)
459 for clip in payload.get("payload", {}).get("clips", []):
460 clip_id = clip.get("id")
461 if clip_id in selected_clips:
462 clip["selected"] = selected_clips[clip_id]
463 return payload
464
465 session_meta = self._session_meta(input_data)
466 video_generation_mode, video_model = self._select_video_model(input_data, session_meta)
467 llm_model = input_data.get("llm_model") or session_meta.get("llm_model") or ""
468 enable_concurrency = input_data.get("enable_concurrency", True)
469 from models.config_model import get_max_concurrency
470 concurrency = get_max_concurrency(video_model, enable_concurrency)
471
472 video_ratio = input_data.get("video_ratio", "16:9")
473 video_resolution = input_data.get("video_resolution", "720P")
474 video_sound = "on"
475 video_shot_type = "multi"
476
477 artifacts = self._session_artifacts(input_data)
478
479 # 1. 获取拍摄片段列表 (从 Storyboard)
480 episodes = artifacts.get('storyboard', {}).get('episodes', [])
481 segments = []
482 for ep in episodes:
483 segments.extend(ep.get("segments", []))
484 if not segments:
485 raise Exception("未找到分镜片段数据,请先完成阶段3")
486
487 video_clips = artifacts.get('video_generation', {}).get('clips', [])
488 rewrite_results_map = self._collect_rewrite_results(video_clips)
489
490 # 2. 获取参考图路径映射 (从 Reference Generation)
491 ref_art = artifacts.get('reference_generation', {})
492 scene_list = ref_art.get('scenes', [])
493 scene_map = {s['id']: s for s in scene_list if 'id' in s}
494 character_art = artifacts.get('character_design', {})
495
496 style_zh = input_data.get('style') or session_meta.get('style') or 'realistic'
497 # 简单映射为中文显示名
498 style_map_zh = {
499 "anime": "动漫",
500 "realistic": "写实",
501 "cartoon": "卡通",
502 "3d-disney": "3D迪斯尼",
503 "oil-painting": "油画",
504 "chinese-ink": "国画",
505 "comic-book": "美漫",
506 "cyberpunk": "赛博朋克"
507 }
508 style_name = style_map_zh.get(style_zh, style_zh)
509 style_prompt = self._get_style_prompt(style_zh)
510
511 # ═══ 介入:重新生成指定片段 ═══
512 if intervention:
513 regen_ids = intervention.get("regenerate_clips", [])
514 if regen_ids:
515 self._report_progress("视频生成", "重新生成中...", 5)
516 segment_map = {s['segment_id']: s for s in segments}
517 clip_map = {c['id']: c for c in video_clips}
518
519 def regen_run():
520 done = 0
521 with ThreadPoolExecutor(max_workers=concurrency) as executor:
522 futs = {}
523 for seg_id in regen_ids:
524 seg = segment_map.get(seg_id)
525 clip = clip_map.get(seg_id) if clip_map.get(seg_id) else None
526 if not seg: continue
527 prompt = self._assemble_prompt(seg, style_prompt, character_art, video_data=clip)
528
529 reference_image_paths = None
530 if video_generation_mode == "reference":
531 img_path = None
532 reference_image_paths = self._get_segment_reference_assets(seg, character_art)
533 if not reference_image_paths:
534 logger.warning("VideoDirectorAgent: %s 参考图模式未匹配到第二阶段人物/场景图", seg_id)
535 else:
536 img_path = self._get_reference_image(sid, seg_id, scene_map)
537 duration = seg.get("total_duration", 10)
538 seg_index = segments.index(seg)
539 last_img_path = self._get_next_reference_image(sid, seg_index, segments, scene_map)
540 if video_generation_mode == "start_end_frame" and not last_img_path:
541 logger.warning("VideoDirectorAgent: %s 首尾帧模式缺少尾帧,回退为首帧生视频入参", seg_id)
542 existing_versions = self._list_versions(sid, seg_id)
543 self._report_progress("视频生成", f"启动生成: {seg_id}", 5, data={
544 "asset_complete": {
545 "type": "clips", "id": seg_id,
546 "status": "running",
547 "versions": existing_versions,
548 }
549 })
550 fut = executor.submit(
551 self._generate_one, sid, seg_id, prompt,
552 img_path, video_model, duration,
553 video_sound, video_shot_type, video_ratio, video_resolution,
554 video_generation_mode, last_img_path, reference_image_paths, llm_model
555 )
556 futs[fut] = seg_id
557 for fut in as_completed(futs):
558 sid_done = futs[fut]
559 try:
560 _, res_path, rewrite_result = fut.result()
561 if rewrite_result:
562 rewrite_results_map[sid_done] = rewrite_result
563 except Exception as e:
564 logger.error(f"Regen future error for {sid_done}: {e}")
565 res_path = None
566 done += 1
567 pct = 5 + int(90 * done / max(1, len(regen_ids)))
568 if res_path:
569 versions = self._list_versions(sid, sid_done)
570 asset_complete = {
571 "type": "clips", "id": sid_done,
572 "status": "done",
573 "selected": res_path,
574 "versions": versions,
575 }
576 if rewrite_results_map.get(sid_done):
577 asset_complete["rewrite_result"] = rewrite_results_map[sid_done]
578 self._report_progress("视频生成", f"完成: {sid_done}", pct, data={
579 "asset_complete": asset_complete
580 })
581 else:
582 asset_complete = {
583 "type": "clips", "id": sid_done,
584 "status": "failed",
585 "selected": "", "versions": [],
586 }
587 if rewrite_results_map.get(sid_done):
588 asset_complete["rewrite_result"] = rewrite_results_map[sid_done]
589 self._report_progress("视频生成", f"失败: {sid_done}", pct, data={
590 "asset_complete": asset_complete
591 })
592 loop = asyncio.get_running_loop()
593 await loop.run_in_executor(None, regen_run)
594
595 # 同步到 session artifacts
596 self._update_session_video_data(sid, segments, style_prompt)
597
598 return self._build_payload(sid, segments, video_clips, rewrite_results_map)
599
600 # ═══ 正常流程:全量生成 ═══
601 self._report_progress("视频生成", "正在准备数据...", 2)
602 preview = self._build_preview(sid, segments, scene_map, video_clips)
603 self._report_progress("视频生成", "加载视频列表", 5, data={"assets_preview": {"clips": preview}})
604
605 def run():
606 tasks = []
607 for seg_index, seg in enumerate(segments):
608 seg_id = seg["segment_id"]
609 existing = self._list_versions(sid, seg_id)
610 if existing: continue
611 prompt = self._assemble_prompt(seg, style_prompt, character_art)
612 reference_image_paths = None
613 if video_generation_mode == "reference":
614 img_path = None
615 reference_image_paths = self._get_segment_reference_assets(seg, character_art)
616 if not reference_image_paths:
617 logger.warning("VideoDirectorAgent: %s 参考图模式未匹配到第二阶段人物/场景图", seg_id)
618 else:
619 img_path = self._get_reference_image(sid, seg_id, scene_map)
620 duration = seg.get("total_duration", 10)
621 last_img_path = self._get_next_reference_image(sid, seg_index, segments, scene_map)
622 if video_generation_mode == "start_end_frame" and not last_img_path:
623 logger.warning("VideoDirectorAgent: %s 首尾帧模式缺少尾帧,回退为首帧生视频入参", seg_id)
624 tasks.append((seg_id, prompt, img_path, duration, last_img_path, reference_image_paths))
625 if not tasks:
626 self._report_progress("视频生成", "所有视频片段已存在", 95)
627 return
628 done = 0
629 with ThreadPoolExecutor(max_workers=concurrency) as executor:
630 futs = {}
631 for seg_id, prompt, img_path, dur, last_img_path, reference_image_paths in tasks:
632 # 提交前立即发送正在运行的状态,让前端 UI 更新
633 self._report_progress("视频生成", f"启动生成: {seg_id}", 5, data={
634 "asset_complete": {
635 "type": "clips", "id": seg_id,
636 "status": "running"
637 }
638 })
639 fut = executor.submit(
640 self._generate_one, sid, seg_id, prompt,
641 img_path, video_model, dur,
642 video_sound, video_shot_type, video_ratio, video_resolution,
643 video_generation_mode, last_img_path, reference_image_paths, llm_model
644 )
645 futs[fut] = seg_id
646 for fut in as_completed(futs):
647 sid_done = futs[fut]
648 try:
649 _, res_path, rewrite_result = fut.result()
650 if rewrite_result:
651 rewrite_results_map[sid_done] = rewrite_result
652 except Exception as e:
653 logger.error(f"Video future error for {sid_done}: {e}")
654 res_path = None
655 done += 1
656 pct = 5 + int(90 * done / max(1, len(tasks)))
657 if res_path:
658 versions = self._list_versions(sid, sid_done)
659 asset_complete = {
660 "type": "clips", "id": sid_done,
661 "status": "done",
662 "selected": res_path,
663 "versions": versions,
664 }
665 if rewrite_results_map.get(sid_done):
666 asset_complete["rewrite_result"] = rewrite_results_map[sid_done]
667 self._report_progress("视频生成", f"完成: {sid_done}", pct, data={
668 "asset_complete": asset_complete
669 })
670 else:
671 asset_complete = {
672 "type": "clips", "id": sid_done,
673 "status": "failed",
674 "selected": "", "versions": [],
675 }
676 if rewrite_results_map.get(sid_done):
677 asset_complete["rewrite_result"] = rewrite_results_map[sid_done]
678 self._report_progress("视频生成", f"失败: {sid_done}", pct, data={
679 "asset_complete": asset_complete
680 })
681 if self.cancellation_check and self.cancellation_check():
682 for f in futs:
683 if not f.done(): f.cancel()
684 break
685 loop = asyncio.get_running_loop()
686 await loop.run_in_executor(None, run)
687
688 # 同步到 session artifacts
689 self._update_session_video_data(sid, segments, style_prompt)
690
691 self._report_progress("视频生成", "完成", 100)
692 return self._build_payload(sid, segments, video_clips, rewrite_results_map)
693
693 lines PYTHON