| 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 |