返回 ViMax
script2video_pipeline.py
根目录 / pipelines / script2video_pipeline.py
1 import os
2 import shutil
3 import json
4 import logging
5 import asyncio
6 import time
7 from typing import Any, Callable, Optional, Dict, List, Tuple, Literal, Type, TypeVar
8 from moviepy import VideoFileClip, concatenate_videoclips
9 from PIL import Image
10 from agents import *
11 import yaml
12 from interfaces import *
13 from langchain.chat_models import init_chat_model
14 from tools.render_backend import RenderBackend
15 from utils.provider_presets import resolve_chat_model_config
16
17
18
19
20 TModel = TypeVar("TModel")
21
22
23 def _normalize_model_list(items: Any, model_cls: Type[TModel], field_name: str) -> List[TModel]:
24 if items is None:
25 return []
26 if not isinstance(items, list):
27 raise TypeError(f"{field_name} must be a list, got {type(items).__name__}")
28 normalized: List[TModel] = []
29 for idx, item in enumerate(items):
30 if isinstance(item, model_cls):
31 normalized.append(item)
32 elif isinstance(item, dict):
33 normalized.append(model_cls.model_validate(item))
34 else:
35 raise TypeError(f"{field_name}[{idx}] must be {model_cls.__name__} or dict, got {type(item).__name__}")
36 return normalized
37
38
39 def _group_shots_into_cameras(shot_descriptions: List[ShotDescription]) -> List[Camera]:
40 cameras_by_idx: Dict[int, Camera] = {}
41 for shot_description in shot_descriptions:
42 camera = cameras_by_idx.get(shot_description.cam_idx)
43 if camera is None:
44 camera = Camera(idx=shot_description.cam_idx, active_shot_idxs=[])
45 cameras_by_idx[shot_description.cam_idx] = camera
46 camera.active_shot_idxs.append(shot_description.idx)
47 return list(cameras_by_idx.values())
48
49 def _collect_priority_shot_idxs(camera_tree: List[Camera]) -> List[int]:
50 """Shot indices that other cameras depend on."""
51 return [camera.parent_shot_idx for camera in camera_tree if camera.parent_shot_idx is not None]
52
53
54 def _pipeline_print(quiet: bool, message: str) -> None:
55 if not quiet:
56 print(message)
57
58
59 def _emit_text_plan_progress(progress, stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None:
60 if progress is not None:
61 progress(stage, message, metadata or {})
62
63
64 def _emit_render_progress(progress, stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None:
65 if progress is not None:
66 progress(stage, message, metadata or {})
67
68
69 def _scoped_progress(progress, **scope):
70 if progress is None:
71 return None
72
73 def emit(stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None:
74 payload = dict(scope)
75 payload.update(metadata or {})
76 _emit_render_progress(progress, stage, message, payload)
77
78 return emit
79
80
81 class Script2VideoPipeline:
82
83 def __init__(
84 self,
85 chat_model: str,
86 image_generator,
87 video_generator,
88 working_dir: str,
89 ):
90
91 self.chat_model = chat_model
92 self.image_generator = image_generator
93 self.video_generator = video_generator
94
95 self.character_extractor = CharacterExtractor(chat_model=self.chat_model)
96 self.character_portraits_generator = CharacterPortraitsGenerator(image_generator=self.image_generator)
97 self.storyboard_artist = StoryboardArtist(chat_model=self.chat_model)
98 self.camera_image_generator = CameraImageGenerator(chat_model=self.chat_model, image_generator=self.image_generator, video_generator=self.video_generator)
99 self.reference_image_selector = ReferenceImageSelector(chat_model=self.chat_model)
100
101 self.working_dir = working_dir
102 os.makedirs(self.working_dir, exist_ok=True)
103 self.character_portrait_events = {}
104 self.shot_desc_events = {}
105 self.frame_events = {}
106
107
108 async def plan_text_artifacts(
109 self,
110 script: str,
111 user_requirement: str,
112 style: str,
113 characters: List[CharacterInScene] = None,
114 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
115 quiet: bool = False,
116 ):
117 """Generate only structured text artifacts required before rendering.
118
119 This helper intentionally stops before character portraits, frame generation,
120 video generation, and final concatenation so an agent loop can pause for
121 user review after narrative planning.
122 """
123 self.character_portrait_events = {}
124 self.shot_desc_events = {}
125 self.frame_events = {}
126
127 if characters is None:
128 _emit_text_plan_progress(progress, "extract_characters", "Extracting characters from script")
129 characters = await self.extract_characters(script=script, quiet=quiet)
130 else:
131 characters = _normalize_model_list(characters, CharacterInScene, "characters")
132 _emit_text_plan_progress(progress, "extract_characters", "Using provided characters", {"provided": True, "count": len(characters)})
133 characters_path = os.path.join(self.working_dir, "characters.json")
134 if not os.path.exists(characters_path):
135 with open(characters_path, "w", encoding="utf-8") as f:
136 json.dump([character.model_dump() for character in characters], f, ensure_ascii=False, indent=4)
137 for character in characters:
138 self.character_portrait_events[character.idx] = asyncio.Event()
139
140 _emit_text_plan_progress(progress, "design_storyboard", "Designing storyboard")
141 storyboard = await self.design_storyboard(
142 script=script,
143 characters=characters,
144 user_requirement=user_requirement,
145 quiet=quiet,
146 )
147 _emit_text_plan_progress(progress, "decompose_shots", "Decomposing shot visual descriptions", {"shot_count": len(storyboard)})
148 shot_descriptions = await self.decompose_visual_descriptions(
149 shot_brief_descriptions=storyboard,
150 characters=characters,
151 quiet=quiet,
152 )
153 camera_tree = None
154 for attempt in range(2):
155 try:
156 stage = "construct_camera_tree" if attempt == 0 else "construct_camera_tree_retry"
157 message = "Constructing camera tree" if attempt == 0 else "Retrying camera tree construction after schema/type failure"
158 _emit_text_plan_progress(progress, stage, message, {"shot_count": len(shot_descriptions), "attempt": attempt + 1})
159 camera_tree = await self.construct_camera_tree(
160 shot_descriptions=shot_descriptions,
161 quiet=quiet,
162 )
163 break
164 except Exception:
165 camera_tree_path = os.path.join(self.working_dir, "camera_tree.json")
166 if os.path.exists(camera_tree_path):
167 os.remove(camera_tree_path)
168 if attempt == 1:
169 raise
170 assert camera_tree is not None
171 return {
172 "characters": characters,
173 "storyboard": storyboard,
174 "shot_descriptions": shot_descriptions,
175 "camera_tree": camera_tree,
176 }
177
178
179 @classmethod
180 def init_from_config(cls, config_path: str):
181 with open(config_path, "r") as f:
182 config = yaml.safe_load(f)
183
184 chat_model_args = resolve_chat_model_config(config["chat_model"]["init_args"])
185 chat_model = init_chat_model(**chat_model_args)
186 backend = RenderBackend.from_config(config)
187
188 return cls(
189 chat_model=chat_model,
190 image_generator=backend.image_generator,
191 video_generator=backend.video_generator,
192 working_dir=config["working_dir"],
193 )
194
195 async def __call__(
196 self,
197 script: str,
198 user_requirement: str,
199 style: str,
200 characters: List[CharacterInScene] = None,
201 character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]] = None,
202 quiet: bool = False,
203 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
204 ):
205 _emit_render_progress(progress, "render_start", "Starting script2video render")
206 if characters is None:
207 _emit_render_progress(progress, "extract_characters", "Extracting characters before render")
208 characters = await self.extract_characters(script=script, quiet=quiet)
209
210 # characters_path = os.path.join(self.working_dir, "characters.json")
211 # if os.path.exists(characters_path):
212 # with open(characters_path, "r", encoding="utf-8") as f:
213 # characters = [CharacterInScene.model_validate(c) for c in json.load(f)]
214 # print(f"🚀 Loaded {len(characters)} characters from existing file.")
215 # else:
216 # print(f"🔍 Extracting characters from script...")
217 # characters = await self.extract_characters(script=script)
218 # with open(characters_path, "w", encoding="utf-8") as f:
219 # json.dump([c.model_dump() for c in characters], f, ensure_ascii=False, indent=4)
220 # print(f"☑️ Extracted {len(characters)} characters from script and saved to {characters_path}.")
221 else:
222 characters = _normalize_model_list(characters, CharacterInScene, "characters")
223 _emit_render_progress(progress, "extract_characters", "Using provided characters for render", {"provided": True, "count": len(characters)})
224 for character in characters:
225 self.character_portrait_events[character.idx] = asyncio.Event()
226
227 if character_portraits_registry is None:
228 character_portraits_registry_path = os.path.join(self.working_dir, "character_portraits_registry.json")
229 if os.path.exists(character_portraits_registry_path):
230 with open(character_portraits_registry_path, "r", encoding="utf-8") as f:
231 character_portraits_registry = json.load(f)
232 print(f"🚀 Loaded {len(character_portraits_registry)} character portraits from existing file.")
233 _emit_render_progress(progress, "character_portraits_loaded", "Loaded existing character portraits", {"count": len(character_portraits_registry)})
234 else:
235 print(f"🔍 Generating character portraits...")
236 _emit_render_progress(progress, "character_portraits_start", "Generating character portraits", {"character_count": len(characters)})
237 character_portraits_registry = await self.generate_character_portraits(
238 characters=characters,
239 character_portraits_registry=None,
240 style=style,
241 progress=progress,
242 )
243
244 with open(character_portraits_registry_path, "w", encoding="utf-8") as f:
245 json.dump(character_portraits_registry, f, ensure_ascii=False, indent=4)
246 print(f"☑️ Generated {len(character_portraits_registry)} character portraits and saved to {character_portraits_registry_path}.")
247 _emit_render_progress(progress, "character_portraits_done", "Character portraits ready", {"count": len(character_portraits_registry)})
248
249
250
251 # design shots
252 _emit_render_progress(progress, "load_storyboard", "Loading or designing storyboard")
253 storyboard = await self.design_storyboard(
254 script=script,
255 characters=characters,
256 user_requirement=user_requirement,
257 quiet=quiet,
258 )
259 _emit_render_progress(progress, "storyboard_ready", "Storyboard ready", {"shot_count": len(storyboard)})
260
261 # decompose visual descriptions of shots
262 _emit_render_progress(progress, "load_shot_descriptions", "Loading or decomposing shot descriptions", {"shot_count": len(storyboard)})
263 shot_descriptions = await self.decompose_visual_descriptions(
264 shot_brief_descriptions=storyboard,
265 characters=characters,
266 quiet=quiet,
267 )
268 _emit_render_progress(progress, "shot_descriptions_ready", "Shot descriptions ready", {"shot_count": len(shot_descriptions)})
269
270 # construct camera tree
271 _emit_render_progress(progress, "load_camera_tree", "Loading or constructing camera tree", {"shot_count": len(shot_descriptions)})
272 camera_tree = await self.construct_camera_tree(
273 shot_descriptions=shot_descriptions,
274 quiet=quiet,
275 )
276 _emit_render_progress(progress, "camera_tree_ready", "Camera tree ready", {"camera_count": len(camera_tree)})
277
278 priority_shot_idxs = [camera.parent_cam_idx for camera in camera_tree if camera.parent_cam_idx is not None]
279 _emit_render_progress(progress, "frames_start", "Generating frames for cameras", {"camera_count": len(camera_tree), "shot_count": len(shot_descriptions)})
280 tasks = [
281 self.generate_frames_for_single_camera(
282 camera=camera,
283 shot_descriptions=shot_descriptions,
284 characters=characters,
285 character_portraits_registry=character_portraits_registry,
286 priority_shot_idxs=priority_shot_idxs,
287 progress=progress,
288 )
289 for camera in camera_tree
290 ]
291
292 _emit_render_progress(progress, "video_clips_start", "Generating video clips for shots", {"shot_count": len(shot_descriptions)})
293 video_tasks = [
294 self.generate_video_for_single_shot(
295 shot_description=shot_description,
296 progress=progress,
297 )
298 for shot_description in shot_descriptions
299 ]
300 tasks.extend(video_tasks)
301 await asyncio.gather(*tasks)
302
303 final_video_path = os.path.join(self.working_dir, "final_video.mp4")
304 if os.path.exists(final_video_path):
305 print(f"🚀 Skipped concatenating videos, already exists.")
306 _emit_render_progress(progress, "final_video_exists", "Final video already exists", {"path": final_video_path})
307 else:
308 print(f"🎬 Starting concatenating videos...")
309 _emit_render_progress(progress, "concat_start", "Concatenating video clips", {"shot_count": len(shot_descriptions)})
310 video_clips = [
311 VideoFileClip(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "video.mp4"))
312 for shot_description in shot_descriptions
313 ]
314 final_video = concatenate_videoclips(video_clips)
315 final_video.write_videofile(final_video_path, codec="libx264", preset="medium")
316 print(f"☑️ Concatenated videos, saved to {final_video_path}.")
317 _emit_render_progress(progress, "concat_done", "Final video concatenated", {"path": final_video_path})
318
319 _emit_render_progress(progress, "render_done", "Script2video render complete", {"final_video_path": final_video_path})
320 return final_video_path
321
322
323 async def generate_frames_for_single_camera(
324 self,
325 camera: Camera,
326 shot_descriptions: List[ShotDescription],
327 characters: List[CharacterInScene],
328 character_portraits_registry: Dict[str, Dict[str, Dict[str, str]]],
329 priority_shot_idxs: List[int],
330 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
331 ):
332 # 1. generate the first_frame of the first shot of the camera
333 first_shot_idx = camera.active_shot_idxs[0]
334 first_shot_ff_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", "first_frame.png")
335 _emit_render_progress(progress, "camera_frames_start", f"Generating frames for camera {camera.idx}", {"camera_idx": camera.idx, "active_shot_idxs": camera.active_shot_idxs})
336
337 if os.path.exists(first_shot_ff_path):
338 print(f"🚀 Skipped generating first_frame for shot {first_shot_idx}, already exists.")
339 self.frame_events[first_shot_idx]["first_frame"].set()
340 _emit_render_progress(progress, "frame_exists", f"First frame for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": first_shot_ff_path})
341
342 else:
343 print(f"🖼️ Starting first_frame generation for shot {first_shot_idx}...")
344 _emit_render_progress(progress, "frame_start", f"Generating first frame for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame"})
345 available_image_path_and_text_pairs = []
346
347 for character_idx in shot_descriptions[first_shot_idx].ff_vis_char_idxs:
348 identifier_in_scene = characters[character_idx].identifier_in_scene
349 registry_item = character_portraits_registry[identifier_in_scene]
350 for view, item in registry_item.items():
351 available_image_path_and_text_pairs.append((item["path"], item["description"]))
352
353 # generate the first_frame based on the shot_description.ff_desc
354 if camera.parent_shot_idx is not None:
355 # generate the first_frame based on the transition video
356 parent_shot_idx = camera.parent_shot_idx
357 await self.frame_events[parent_shot_idx]["first_frame"].wait()
358 parent_shot_ff_path = os.path.join(self.working_dir, "shots", f"{parent_shot_idx}", "first_frame.png")
359 transition_video_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", f"transition_video_from_shot_{parent_shot_idx}.mp4")
360
361 if os.path.exists(transition_video_path):
362 print(f"🚀 Skipped generating transition video for shot {first_shot_idx} from shot {parent_shot_idx}, already exists.")
363 _emit_render_progress(progress, "transition_video_exists", f"Transition video for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "parent_shot_idx": parent_shot_idx, "path": transition_video_path})
364 else:
365 print(f"🖼️ Starting transition video generation for shot {first_shot_idx} from shot {parent_shot_idx}...")
366 _emit_render_progress(progress, "transition_video_start", f"Generating transition video for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "parent_shot_idx": parent_shot_idx})
367 transition_video_output = await self.camera_image_generator.generate_transition_video(
368 first_shot_visual_desc=shot_descriptions[parent_shot_idx].visual_desc,
369 second_shot_visual_desc=shot_descriptions[first_shot_idx].visual_desc,
370 first_shot_ff_path=parent_shot_ff_path,
371 progress=_scoped_progress(progress, camera_idx=camera.idx, shot_idx=first_shot_idx, parent_shot_idx=parent_shot_idx, artifact="transition_video"),
372 )
373 transition_video_output.save(transition_video_path)
374 print(f"☑️ Generated transition video for shot {first_shot_idx} from shot {parent_shot_idx}, saved to {transition_video_path}.")
375 _emit_render_progress(progress, "transition_video_done", f"Transition video for shot {first_shot_idx} generated", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "parent_shot_idx": parent_shot_idx, "path": transition_video_path})
376
377 new_camera_image_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", f"new_camera_{camera.idx}.png")
378 if os.path.exists(new_camera_image_path):
379 print(f"🚀 Skipped generating new camera image for shot {first_shot_idx}, already exists.")
380 _emit_render_progress(progress, "new_camera_image_exists", f"New camera image for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "path": new_camera_image_path})
381 else:
382 print(f"🖼️ Starting new camera image generation for shot {first_shot_idx}...")
383 _emit_render_progress(progress, "new_camera_image_start", f"Extracting new camera image for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx})
384 new_camera_image = self.camera_image_generator.get_new_camera_image(transition_video_path)
385 new_camera_image.save(new_camera_image_path)
386 print(f"☑️ Generated new camera image for shot {first_shot_idx} (not completed), saved to {new_camera_image_path}.")
387 _emit_render_progress(progress, "new_camera_image_done", f"New camera image for shot {first_shot_idx} extracted", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "path": new_camera_image_path})
388
389 available_image_path_and_text_pairs.append(
390 (
391 new_camera_image_path,
392 f"The composition and background are correct but some elements may be wrong. The wrong elements should be replaced.\nWrong elements: {camera.missing_info}.\nYou must select this image as the main reference and replace the characters in the image with the provided character portraits. Don't change the background."
393 )
394 )
395
396
397 # 如果子镜头缺少信息,则需要选择参考图像生成
398 if camera.parent_shot_idx is None or camera.missing_info is not None:
399 ff_selector_output_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", "first_frame_selector_output.json")
400 if os.path.exists(ff_selector_output_path):
401 with open(ff_selector_output_path, 'r', encoding='utf-8') as f:
402 ff_selector_output = json.load(f)
403 print(f"🚀 Loaded existing reference image selection and prompt for first_frame of shot {first_shot_idx} from {ff_selector_output_path}.")
404 _emit_render_progress(progress, "frame_prompt_exists", f"First frame prompt for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": ff_selector_output_path})
405 else:
406 print(f"🔍 Selecting reference images and generating prompt for first_frame of shot {first_shot_idx}...")
407 _emit_render_progress(progress, "frame_prompt_start", f"Selecting references for first frame of shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame"})
408 ff_selector_output = await self.reference_image_selector.select_reference_images_and_generate_prompt(
409 available_image_path_and_text_pairs=available_image_path_and_text_pairs,
410 frame_description=shot_descriptions[first_shot_idx].ff_desc
411 )
412 with open(ff_selector_output_path, 'w', encoding='utf-8') as f:
413 json.dump(ff_selector_output, f, ensure_ascii=False, indent=4)
414
415 print(f"☑️ Selected reference images and generated prompt for first_frame of shot {first_shot_idx}, saved to {ff_selector_output_path}.")
416 _emit_render_progress(progress, "frame_prompt_done", f"Selected references for first frame of shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": ff_selector_output_path})
417
418 reference_image_path_and_text_pairs, prompt = ff_selector_output["reference_image_path_and_text_pairs"], ff_selector_output["text_prompt"]
419 prefix_prompt = ""
420 for i, (image_path, text) in enumerate(reference_image_path_and_text_pairs):
421 prefix_prompt += f"Image {i}: {text}\n"
422 prompt = f"{prefix_prompt}\n{prompt}"
423 reference_image_paths = [item[0] for item in reference_image_path_and_text_pairs]
424 ff_image: ImageOutput = await self.image_generator.generate_single_image(
425 prompt=prompt,
426 reference_image_paths=reference_image_paths,
427 size="1600x900",
428 )
429 ff_image.save(first_shot_ff_path)
430 self.frame_events[first_shot_idx]["first_frame"].set()
431 print(f"☑️ Generated first_frame for shot {first_shot_idx}, saved to {first_shot_ff_path}.")
432 _emit_render_progress(progress, "frame_done", f"Generated first frame for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": first_shot_ff_path})
433 else:
434 shutil.copy(new_camera_image_path, first_shot_ff_path)
435 self.frame_events[first_shot_idx]["first_frame"].set()
436 print(f"☑️ Generated first_frame for shot {first_shot_idx}, saved to {first_shot_ff_path}.")
437 _emit_render_progress(progress, "frame_done", f"Generated first frame for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": first_shot_ff_path})
438
439
440 # 2. generate the following frames of the camera
441 priority_tasks = []
442 normal_tasks = []
443
444 if shot_descriptions[first_shot_idx].variation_type in ["medium", "large"]:
445 task = self.generate_frame_for_single_shot(
446 shot_idx=first_shot_idx,
447 frame_type="last_frame",
448 first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc),
449 frame_desc=shot_descriptions[first_shot_idx].lf_desc,
450 visible_characters=[characters[idx] for idx in shot_descriptions[first_shot_idx].lf_vis_char_idxs],
451 character_portraits_registry=character_portraits_registry,
452 progress=progress,
453 )
454 normal_tasks.append(task)
455
456 for shot_idx in camera.active_shot_idxs[1:]:
457 first_frame_task = self.generate_frame_for_single_shot(
458 shot_idx=shot_idx,
459 frame_type="first_frame",
460 first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc),
461 frame_desc=shot_descriptions[shot_idx].ff_desc,
462 visible_characters=[characters[idx] for idx in shot_descriptions[shot_idx].ff_vis_char_idxs],
463 character_portraits_registry=character_portraits_registry,
464 progress=progress,
465 )
466 if shot_idx in priority_shot_idxs:
467 priority_tasks.append(first_frame_task)
468 else:
469 normal_tasks.append(first_frame_task)
470
471
472 if shot_descriptions[shot_idx].variation_type in ["medium", "large"]:
473 last_frame_task = self.generate_frame_for_single_shot(
474 shot_idx=shot_idx,
475 frame_type="last_frame",
476 first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc),
477 frame_desc=shot_descriptions[shot_idx].lf_desc,
478 visible_characters=[characters[idx] for idx in shot_descriptions[shot_idx].lf_vis_char_idxs],
479 character_portraits_registry=character_portraits_registry,
480 progress=progress,
481 )
482 normal_tasks.append(last_frame_task)
483
484
485 await asyncio.gather(*priority_tasks)
486 await asyncio.gather(*normal_tasks)
487 _emit_render_progress(progress, "camera_frames_done", f"Frames for camera {camera.idx} ready", {"camera_idx": camera.idx, "active_shot_idxs": camera.active_shot_idxs})
488
489
490
491 async def generate_video_for_single_shot(
492 self,
493 shot_description: ShotDescription,
494 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
495 ):
496 video_path = os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "video.mp4")
497 if os.path.exists(video_path):
498 print(f"🚀 Skipped generating video for shot {shot_description.idx}, already exists.")
499 _emit_render_progress(progress, "video_clip_exists", f"Video clip for shot {shot_description.idx} already exists", {"shot_idx": shot_description.idx, "path": video_path})
500 else:
501 _emit_render_progress(progress, "video_clip_waiting_for_frames", f"Waiting for frames before video clip {shot_description.idx}", {"shot_idx": shot_description.idx})
502 await self.frame_events[shot_description.idx]["first_frame"].wait()
503 if shot_description.variation_type in ["medium", "large"]:
504 await self.frame_events[shot_description.idx]["last_frame"].wait()
505
506 frame_paths = []
507 frame_paths.append(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "first_frame.png"))
508 if shot_description.variation_type in ["medium", "large"]:
509 frame_paths.append(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "last_frame.png"))
510
511 print(f"🎬 Starting video generation for shot {shot_description.idx}...")
512 _emit_render_progress(progress, "video_clip_start", f"Generating video clip for shot {shot_description.idx}", {"shot_idx": shot_description.idx, "frame_count": len(frame_paths)})
513 video_output = await self.video_generator.generate_single_video(
514 prompt=shot_description.motion_desc + "\n" + shot_description.audio_desc,
515 reference_image_paths=frame_paths,
516 progress=_scoped_progress(progress, shot_idx=shot_description.idx, artifact="video_clip"),
517 )
518 video_output.save(video_path)
519 print(f"☑️ Generated video for shot {shot_description.idx}, saved to {video_path}.")
520 _emit_render_progress(progress, "video_clip_done", f"Generated video clip for shot {shot_description.idx}", {"shot_idx": shot_description.idx, "path": video_path})
521
522 async def generate_frame_for_single_shot(
523 self,
524 shot_idx: int,
525 frame_type: Literal["first_frame", "last_frame"],
526 first_shot_ff_path_and_text_pair: Tuple[str, str],
527 frame_desc: str,
528 visible_characters: List[CharacterInScene],
529 character_portraits_registry: Dict[str, Dict[str, Dict[str, str]]],
530 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
531 ) -> ImageOutput:
532
533 frame_image_path = os.path.join(self.working_dir, "shots", f"{shot_idx}", f"{frame_type}.png")
534
535 if os.path.exists(frame_image_path):
536 print(f"🚀 Skipped generating {frame_type} for shot {shot_idx}, already exists.")
537 _emit_render_progress(progress, "frame_exists", f"{frame_type} for shot {shot_idx} already exists", {"shot_idx": shot_idx, "frame_type": frame_type, "path": frame_image_path})
538
539 else:
540 print(f"🖼️ Starting {frame_type} generation for shot {shot_idx}...")
541 _emit_render_progress(progress, "frame_start", f"Generating {frame_type} for shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type})
542 available_image_path_and_text_pairs = []
543 for visible_character in visible_characters:
544 identifier_in_scene = visible_character.identifier_in_scene
545 registry_item = character_portraits_registry[identifier_in_scene]
546 for view, item in registry_item.items():
547 available_image_path_and_text_pairs.append((item["path"], item["description"]))
548
549 available_image_path_and_text_pairs.append(first_shot_ff_path_and_text_pair)
550
551 selector_output_path = os.path.join(self.working_dir, "shots", f"{shot_idx}", f"{frame_type}_selector_output.json")
552 if os.path.exists(selector_output_path):
553 with open(selector_output_path, 'r', encoding='utf-8') as f:
554 selector_output = json.load(f)
555 print(f"🚀 Loaded existing reference image selection and prompt for {frame_type} frame of shot {shot_idx} from {selector_output_path}.")
556 _emit_render_progress(progress, "frame_prompt_exists", f"Prompt for {frame_type} of shot {shot_idx} already exists", {"shot_idx": shot_idx, "frame_type": frame_type, "path": selector_output_path})
557 else:
558 print(f"🔍 Selecting reference images and generating prompt for {frame_type} frame of shot {shot_idx}...")
559 _emit_render_progress(progress, "frame_prompt_start", f"Selecting references for {frame_type} of shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type})
560 selector_output = await self.reference_image_selector.select_reference_images_and_generate_prompt(
561 available_image_path_and_text_pairs=available_image_path_and_text_pairs,
562 frame_description=frame_desc
563 )
564 with open(selector_output_path, 'w', encoding='utf-8') as f:
565 json.dump(selector_output, f, ensure_ascii=False, indent=4)
566 print(f"☑️ Selected reference images and generated prompt for {frame_type} frame of shot {shot_idx}, saved to {selector_output_path}.")
567 _emit_render_progress(progress, "frame_prompt_done", f"Selected references for {frame_type} of shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type, "path": selector_output_path})
568
569 reference_image_path_and_text_pairs, prompt = selector_output["reference_image_path_and_text_pairs"], selector_output["text_prompt"]
570 prefix_prompt = ""
571 for i, (image_path, text) in enumerate(reference_image_path_and_text_pairs):
572 prefix_prompt += f"Image {i}: {text}\n"
573 prompt = f"{prefix_prompt}\n{prompt}"
574 reference_image_paths = [item[0] for item in reference_image_path_and_text_pairs]
575
576 frame_image: ImageOutput = await self.image_generator.generate_single_image(
577 prompt=prompt,
578 reference_image_paths=reference_image_paths,
579 size="1600x900",
580 )
581 frame_image.save(frame_image_path)
582 print(f"☑️ Generated {frame_type} frame for shot {shot_idx}, saved to {frame_image_path}.")
583 _emit_render_progress(progress, "frame_done", f"Generated {frame_type} for shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type, "path": frame_image_path})
584
585
586 self.frame_events[shot_idx][frame_type].set()
587 return frame_image_path
588
589
590 async def construct_camera_tree(
591 self,
592 shot_descriptions: List[ShotDescription],
593 quiet: bool = False,
594 ):
595 camera_tree_path = os.path.join(self.working_dir, "camera_tree.json")
596
597 if os.path.exists(camera_tree_path):
598 with open(camera_tree_path, "r", encoding="utf-8") as f:
599 camera_tree = json.load(f)
600 camera_tree = [Camera.model_validate(camera) for camera in camera_tree]
601 _pipeline_print(quiet, f"🚀 Loaded {len(camera_tree)} cameras from existing file.")
602 return camera_tree
603
604 shot_descriptions = _normalize_model_list(shot_descriptions, ShotDescription, "shot_descriptions")
605 cameras = _group_shots_into_cameras(shot_descriptions)
606
607 camera_tree = await self.camera_image_generator.construct_camera_tree(cameras=cameras, shot_descs=shot_descriptions)
608 camera_tree = _normalize_model_list(camera_tree, Camera, "camera_tree")
609 with open(camera_tree_path, "w", encoding="utf-8") as f:
610 json.dump([camera.model_dump() for camera in camera_tree], f, ensure_ascii=False, indent=4)
611 _pipeline_print(quiet, f"✅ Constructed camera tree and saved to {camera_tree_path}.")
612 return camera_tree
613
614
615
616
617 async def extract_characters(
618 self,
619 script: str,
620 quiet: bool = False,
621 ):
622 save_path = os.path.join(self.working_dir, "characters.json")
623
624 if os.path.exists(save_path):
625 with open(save_path, "r", encoding="utf-8") as f:
626 characters = json.load(f)
627 characters = [CharacterInScene.model_validate(character) for character in characters]
628 _pipeline_print(quiet, f"🚀 Loaded {len(characters)} characters from existing file.")
629 else:
630 characters = await self.character_extractor.extract_characters(script)
631 with open(save_path, "w", encoding="utf-8") as f:
632 json.dump([character.model_dump() for character in characters], f, ensure_ascii=False, indent=4)
633 _pipeline_print(quiet, f"✅ Extracted {len(characters)} characters from script and saved to {save_path}.")
634
635 for character in characters:
636 self.character_portrait_events[character.idx] = asyncio.Event()
637
638 return characters
639
640
641 async def generate_character_portraits(
642 self,
643 characters: List[CharacterInScene],
644 character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]],
645 style: str,
646 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
647 ):
648 character_portraits_registry_path = os.path.join(self.working_dir, "character_portraits_registry.json")
649 if character_portraits_registry is None:
650 if os.path.exists(character_portraits_registry_path):
651 with open(character_portraits_registry_path, 'r', encoding='utf-8') as f:
652 character_portraits_registry = json.load(f)
653 else:
654 character_portraits_registry = {}
655
656
657 tasks = [
658 self.generate_portraits_for_single_character(character, style, progress=progress)
659 for character in characters
660 if character.identifier_in_scene not in character_portraits_registry
661 ]
662 if tasks:
663 for future in asyncio.as_completed(tasks):
664 character_portraits_registry.update(await future)
665 with open(character_portraits_registry_path, 'w', encoding='utf-8') as f:
666 json.dump(character_portraits_registry, f, ensure_ascii=False, indent=4)
667
668 print(f"✅ Completed character portrait generation for {len(characters)} characters.")
669 _emit_render_progress(progress, "character_portraits_done", "Completed character portrait generation", {"character_count": len(characters)})
670 else:
671 print("🚀 All characters already have portraits, skipping portrait generation.")
672 _emit_render_progress(progress, "character_portraits_exist", "All character portraits already exist", {"character_count": len(characters)})
673 return character_portraits_registry
674
675
676 async def generate_portraits_for_single_character(
677 self,
678 character: CharacterInScene,
679 style: str,
680 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
681 ):
682 character_dir = os.path.join(self.working_dir, "character_portraits", f"{character.idx}_{character.identifier_in_scene}")
683 os.makedirs(character_dir, exist_ok=True)
684 _emit_render_progress(progress, "character_portrait_start", f"Generating portraits for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
685
686 front_portrait_path = os.path.join(character_dir, "front.png")
687 if os.path.exists(front_portrait_path):
688 pass
689 else:
690 _emit_render_progress(progress, "character_portrait_front_start", f"Generating front portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
691 front_portrait_output = await self.character_portraits_generator.generate_front_portrait(character, style)
692 front_portrait_output.save(front_portrait_path)
693 _emit_render_progress(progress, "character_portrait_front_done", f"Generated front portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene, "path": front_portrait_path})
694
695
696 side_portrait_path = os.path.join(character_dir, "side.png")
697 if os.path.exists(side_portrait_path):
698 pass
699 else:
700 _emit_render_progress(progress, "character_portrait_side_start", f"Generating side portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
701 side_portrait_output = await self.character_portraits_generator.generate_side_portrait(character, front_portrait_path)
702 side_portrait_output.save(side_portrait_path)
703 _emit_render_progress(progress, "character_portrait_side_done", f"Generated side portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene, "path": side_portrait_path})
704
705 back_portrait_path = os.path.join(character_dir, "back.png")
706 if os.path.exists(back_portrait_path):
707 pass
708 else:
709 _emit_render_progress(progress, "character_portrait_back_start", f"Generating back portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
710 back_portrait_output = await self.character_portraits_generator.generate_back_portrait(character, front_portrait_path)
711 back_portrait_output.save(back_portrait_path)
712 _emit_render_progress(progress, "character_portrait_back_done", f"Generated back portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene, "path": back_portrait_path})
713
714 self.character_portrait_events[character.idx].set()
715
716 print(f"☑️ Completed character portrait generation for {character.identifier_in_scene}.")
717 _emit_render_progress(progress, "character_portrait_done", f"Portraits for {character.identifier_in_scene} ready", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
718
719 return {
720 character.identifier_in_scene: {
721 "front": {
722 "path": front_portrait_path,
723 "description": f"A front view portrait of {character.identifier_in_scene}.",
724 },
725 "side": {
726 "path": side_portrait_path,
727 "description": f"A side view portrait of {character.identifier_in_scene}.",
728 },
729 "back": {
730 "path": back_portrait_path,
731 "description": f"A back view portrait of {character.identifier_in_scene}.",
732 },
733 }
734 }
735
736
737
738 async def design_storyboard(
739 self,
740 script: str,
741 characters: List[CharacterInScene],
742 user_requirement: str,
743 quiet: bool = False,
744 ):
745 storyboard_path = os.path.join(self.working_dir, "storyboard.json")
746 if os.path.exists(storyboard_path):
747 with open(storyboard_path, 'r', encoding='utf-8') as f:
748 storyboard = json.load(f)
749 storyboard = [ShotBriefDescription.model_validate(shot) for shot in storyboard]
750 _pipeline_print(quiet, f"🚀 Loaded {len(storyboard)} shot brief descriptions from existing file.")
751 else:
752 _pipeline_print(quiet, f"🔍 Designing storyboard...")
753 storyboard = await self.storyboard_artist.design_storyboard(
754 script=script,
755 characters=characters,
756 user_requirement=user_requirement,
757 retry_timeout=150,
758 )
759 storyboard = _normalize_model_list(storyboard, ShotBriefDescription, "storyboard")
760 with open(storyboard_path, 'w', encoding='utf-8') as f:
761 json.dump([shot.model_dump() for shot in storyboard], f, ensure_ascii=False, indent=4)
762 _pipeline_print(quiet, f"✅ Designed storyboard and saved to {storyboard_path}.")
763
764 for shot_brief_description in storyboard:
765 self.shot_desc_events[shot_brief_description.idx] = asyncio.Event()
766
767 return storyboard
768
769
770
771 async def decompose_visual_descriptions(
772 self,
773 shot_brief_descriptions: List[ShotBriefDescription],
774 characters: List[CharacterInScene],
775 quiet: bool = False,
776 ):
777 tasks = [
778 self.decompose_visual_description_for_single_shot_brief_description(shot_brief_description, characters, quiet=quiet)
779 for shot_brief_description in shot_brief_descriptions
780 ]
781
782 shot_descriptions = await asyncio.gather(*tasks)
783 return shot_descriptions
784
785
786 async def decompose_visual_description_for_single_shot_brief_description(
787 self,
788 shot_brief_description: ShotBriefDescription,
789 characters: List[CharacterInScene],
790 quiet: bool = False,
791 ):
792 shot_description_path = os.path.join(self.working_dir, "shots", f"{shot_brief_description.idx}", "shot_description.json")
793 os.makedirs(os.path.dirname(shot_description_path), exist_ok=True)
794
795 if os.path.exists(shot_description_path):
796 with open(shot_description_path, 'r', encoding='utf-8') as f:
797 shot_description = ShotDescription.model_validate(json.load(f))
798 _pipeline_print(quiet, f"🚀 Loaded shot {shot_brief_description.idx} description from existing file.")
799 else:
800 shot_description = await self.storyboard_artist.decompose_visual_description(
801 shot_brief_desc=shot_brief_description,
802 characters=characters,
803 retry_timeout=120,
804 )
805 shot_description = _normalize_model_list([shot_description], ShotDescription, "shot_description")[0]
806 with open(shot_description_path, 'w', encoding='utf-8') as f:
807 json.dump(shot_description.model_dump(), f, ensure_ascii=False, indent=4)
808 _pipeline_print(quiet, f"✅ Decomposed visual description for shot {shot_brief_description.idx} and saved to {shot_description_path}.")
809
810 self.shot_desc_events[shot_brief_description.idx].set()
811
812 if shot_description.variation_type in ["medium", "large"]:
813 self.frame_events[shot_brief_description.idx] = {
814 "first_frame": asyncio.Event(),
815 "last_frame": asyncio.Event(),
816 }
817 else:
818 self.frame_events[shot_brief_description.idx] = {
819 "first_frame": asyncio.Event(),
820 }
821
822 return shot_description
823
823 lines PYTHON