Files
hkuds--vimax/pipelines/script2video_pipeline.py
2026-07-13 12:23:54 +08:00

823 lines
46 KiB
Python

import os
import shutil
import json
import logging
import asyncio
import time
from typing import Any, Callable, Optional, Dict, List, Tuple, Literal, Type, TypeVar
from moviepy import VideoFileClip, concatenate_videoclips
from PIL import Image
from agents import *
import yaml
from interfaces import *
from langchain.chat_models import init_chat_model
from tools.render_backend import RenderBackend
from utils.provider_presets import resolve_chat_model_config
TModel = TypeVar("TModel")
def _normalize_model_list(items: Any, model_cls: Type[TModel], field_name: str) -> List[TModel]:
if items is None:
return []
if not isinstance(items, list):
raise TypeError(f"{field_name} must be a list, got {type(items).__name__}")
normalized: List[TModel] = []
for idx, item in enumerate(items):
if isinstance(item, model_cls):
normalized.append(item)
elif isinstance(item, dict):
normalized.append(model_cls.model_validate(item))
else:
raise TypeError(f"{field_name}[{idx}] must be {model_cls.__name__} or dict, got {type(item).__name__}")
return normalized
def _group_shots_into_cameras(shot_descriptions: List[ShotDescription]) -> List[Camera]:
cameras_by_idx: Dict[int, Camera] = {}
for shot_description in shot_descriptions:
camera = cameras_by_idx.get(shot_description.cam_idx)
if camera is None:
camera = Camera(idx=shot_description.cam_idx, active_shot_idxs=[])
cameras_by_idx[shot_description.cam_idx] = camera
camera.active_shot_idxs.append(shot_description.idx)
return list(cameras_by_idx.values())
def _collect_priority_shot_idxs(camera_tree: List[Camera]) -> List[int]:
"""Shot indices that other cameras depend on."""
return [camera.parent_shot_idx for camera in camera_tree if camera.parent_shot_idx is not None]
def _pipeline_print(quiet: bool, message: str) -> None:
if not quiet:
print(message)
def _emit_text_plan_progress(progress, stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None:
if progress is not None:
progress(stage, message, metadata or {})
def _emit_render_progress(progress, stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None:
if progress is not None:
progress(stage, message, metadata or {})
def _scoped_progress(progress, **scope):
if progress is None:
return None
def emit(stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None:
payload = dict(scope)
payload.update(metadata or {})
_emit_render_progress(progress, stage, message, payload)
return emit
class Script2VideoPipeline:
def __init__(
self,
chat_model: str,
image_generator,
video_generator,
working_dir: str,
):
self.chat_model = chat_model
self.image_generator = image_generator
self.video_generator = video_generator
self.character_extractor = CharacterExtractor(chat_model=self.chat_model)
self.character_portraits_generator = CharacterPortraitsGenerator(image_generator=self.image_generator)
self.storyboard_artist = StoryboardArtist(chat_model=self.chat_model)
self.camera_image_generator = CameraImageGenerator(chat_model=self.chat_model, image_generator=self.image_generator, video_generator=self.video_generator)
self.reference_image_selector = ReferenceImageSelector(chat_model=self.chat_model)
self.working_dir = working_dir
os.makedirs(self.working_dir, exist_ok=True)
self.character_portrait_events = {}
self.shot_desc_events = {}
self.frame_events = {}
async def plan_text_artifacts(
self,
script: str,
user_requirement: str,
style: str,
characters: List[CharacterInScene] = None,
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
quiet: bool = False,
):
"""Generate only structured text artifacts required before rendering.
This helper intentionally stops before character portraits, frame generation,
video generation, and final concatenation so an agent loop can pause for
user review after narrative planning.
"""
self.character_portrait_events = {}
self.shot_desc_events = {}
self.frame_events = {}
if characters is None:
_emit_text_plan_progress(progress, "extract_characters", "Extracting characters from script")
characters = await self.extract_characters(script=script, quiet=quiet)
else:
characters = _normalize_model_list(characters, CharacterInScene, "characters")
_emit_text_plan_progress(progress, "extract_characters", "Using provided characters", {"provided": True, "count": len(characters)})
characters_path = os.path.join(self.working_dir, "characters.json")
if not os.path.exists(characters_path):
with open(characters_path, "w", encoding="utf-8") as f:
json.dump([character.model_dump() for character in characters], f, ensure_ascii=False, indent=4)
for character in characters:
self.character_portrait_events[character.idx] = asyncio.Event()
_emit_text_plan_progress(progress, "design_storyboard", "Designing storyboard")
storyboard = await self.design_storyboard(
script=script,
characters=characters,
user_requirement=user_requirement,
quiet=quiet,
)
_emit_text_plan_progress(progress, "decompose_shots", "Decomposing shot visual descriptions", {"shot_count": len(storyboard)})
shot_descriptions = await self.decompose_visual_descriptions(
shot_brief_descriptions=storyboard,
characters=characters,
quiet=quiet,
)
camera_tree = None
for attempt in range(2):
try:
stage = "construct_camera_tree" if attempt == 0 else "construct_camera_tree_retry"
message = "Constructing camera tree" if attempt == 0 else "Retrying camera tree construction after schema/type failure"
_emit_text_plan_progress(progress, stage, message, {"shot_count": len(shot_descriptions), "attempt": attempt + 1})
camera_tree = await self.construct_camera_tree(
shot_descriptions=shot_descriptions,
quiet=quiet,
)
break
except Exception:
camera_tree_path = os.path.join(self.working_dir, "camera_tree.json")
if os.path.exists(camera_tree_path):
os.remove(camera_tree_path)
if attempt == 1:
raise
assert camera_tree is not None
return {
"characters": characters,
"storyboard": storyboard,
"shot_descriptions": shot_descriptions,
"camera_tree": camera_tree,
}
@classmethod
def init_from_config(cls, config_path: str):
with open(config_path, "r") as f:
config = yaml.safe_load(f)
chat_model_args = resolve_chat_model_config(config["chat_model"]["init_args"])
chat_model = init_chat_model(**chat_model_args)
backend = RenderBackend.from_config(config)
return cls(
chat_model=chat_model,
image_generator=backend.image_generator,
video_generator=backend.video_generator,
working_dir=config["working_dir"],
)
async def __call__(
self,
script: str,
user_requirement: str,
style: str,
characters: List[CharacterInScene] = None,
character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]] = None,
quiet: bool = False,
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
):
_emit_render_progress(progress, "render_start", "Starting script2video render")
if characters is None:
_emit_render_progress(progress, "extract_characters", "Extracting characters before render")
characters = await self.extract_characters(script=script, quiet=quiet)
# characters_path = os.path.join(self.working_dir, "characters.json")
# if os.path.exists(characters_path):
# with open(characters_path, "r", encoding="utf-8") as f:
# characters = [CharacterInScene.model_validate(c) for c in json.load(f)]
# print(f"🚀 Loaded {len(characters)} characters from existing file.")
# else:
# print(f"🔍 Extracting characters from script...")
# characters = await self.extract_characters(script=script)
# with open(characters_path, "w", encoding="utf-8") as f:
# json.dump([c.model_dump() for c in characters], f, ensure_ascii=False, indent=4)
# print(f"☑️ Extracted {len(characters)} characters from script and saved to {characters_path}.")
else:
characters = _normalize_model_list(characters, CharacterInScene, "characters")
_emit_render_progress(progress, "extract_characters", "Using provided characters for render", {"provided": True, "count": len(characters)})
for character in characters:
self.character_portrait_events[character.idx] = asyncio.Event()
if character_portraits_registry is None:
character_portraits_registry_path = os.path.join(self.working_dir, "character_portraits_registry.json")
if os.path.exists(character_portraits_registry_path):
with open(character_portraits_registry_path, "r", encoding="utf-8") as f:
character_portraits_registry = json.load(f)
print(f"🚀 Loaded {len(character_portraits_registry)} character portraits from existing file.")
_emit_render_progress(progress, "character_portraits_loaded", "Loaded existing character portraits", {"count": len(character_portraits_registry)})
else:
print(f"🔍 Generating character portraits...")
_emit_render_progress(progress, "character_portraits_start", "Generating character portraits", {"character_count": len(characters)})
character_portraits_registry = await self.generate_character_portraits(
characters=characters,
character_portraits_registry=None,
style=style,
progress=progress,
)
with open(character_portraits_registry_path, "w", encoding="utf-8") as f:
json.dump(character_portraits_registry, f, ensure_ascii=False, indent=4)
print(f"☑️ Generated {len(character_portraits_registry)} character portraits and saved to {character_portraits_registry_path}.")
_emit_render_progress(progress, "character_portraits_done", "Character portraits ready", {"count": len(character_portraits_registry)})
# design shots
_emit_render_progress(progress, "load_storyboard", "Loading or designing storyboard")
storyboard = await self.design_storyboard(
script=script,
characters=characters,
user_requirement=user_requirement,
quiet=quiet,
)
_emit_render_progress(progress, "storyboard_ready", "Storyboard ready", {"shot_count": len(storyboard)})
# decompose visual descriptions of shots
_emit_render_progress(progress, "load_shot_descriptions", "Loading or decomposing shot descriptions", {"shot_count": len(storyboard)})
shot_descriptions = await self.decompose_visual_descriptions(
shot_brief_descriptions=storyboard,
characters=characters,
quiet=quiet,
)
_emit_render_progress(progress, "shot_descriptions_ready", "Shot descriptions ready", {"shot_count": len(shot_descriptions)})
# construct camera tree
_emit_render_progress(progress, "load_camera_tree", "Loading or constructing camera tree", {"shot_count": len(shot_descriptions)})
camera_tree = await self.construct_camera_tree(
shot_descriptions=shot_descriptions,
quiet=quiet,
)
_emit_render_progress(progress, "camera_tree_ready", "Camera tree ready", {"camera_count": len(camera_tree)})
priority_shot_idxs = [camera.parent_cam_idx for camera in camera_tree if camera.parent_cam_idx is not None]
_emit_render_progress(progress, "frames_start", "Generating frames for cameras", {"camera_count": len(camera_tree), "shot_count": len(shot_descriptions)})
tasks = [
self.generate_frames_for_single_camera(
camera=camera,
shot_descriptions=shot_descriptions,
characters=characters,
character_portraits_registry=character_portraits_registry,
priority_shot_idxs=priority_shot_idxs,
progress=progress,
)
for camera in camera_tree
]
_emit_render_progress(progress, "video_clips_start", "Generating video clips for shots", {"shot_count": len(shot_descriptions)})
video_tasks = [
self.generate_video_for_single_shot(
shot_description=shot_description,
progress=progress,
)
for shot_description in shot_descriptions
]
tasks.extend(video_tasks)
await asyncio.gather(*tasks)
final_video_path = os.path.join(self.working_dir, "final_video.mp4")
if os.path.exists(final_video_path):
print(f"🚀 Skipped concatenating videos, already exists.")
_emit_render_progress(progress, "final_video_exists", "Final video already exists", {"path": final_video_path})
else:
print(f"🎬 Starting concatenating videos...")
_emit_render_progress(progress, "concat_start", "Concatenating video clips", {"shot_count": len(shot_descriptions)})
video_clips = [
VideoFileClip(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "video.mp4"))
for shot_description in shot_descriptions
]
final_video = concatenate_videoclips(video_clips)
final_video.write_videofile(final_video_path, codec="libx264", preset="medium")
print(f"☑️ Concatenated videos, saved to {final_video_path}.")
_emit_render_progress(progress, "concat_done", "Final video concatenated", {"path": final_video_path})
_emit_render_progress(progress, "render_done", "Script2video render complete", {"final_video_path": final_video_path})
return final_video_path
async def generate_frames_for_single_camera(
self,
camera: Camera,
shot_descriptions: List[ShotDescription],
characters: List[CharacterInScene],
character_portraits_registry: Dict[str, Dict[str, Dict[str, str]]],
priority_shot_idxs: List[int],
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
):
# 1. generate the first_frame of the first shot of the camera
first_shot_idx = camera.active_shot_idxs[0]
first_shot_ff_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", "first_frame.png")
_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})
if os.path.exists(first_shot_ff_path):
print(f"🚀 Skipped generating first_frame for shot {first_shot_idx}, already exists.")
self.frame_events[first_shot_idx]["first_frame"].set()
_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})
else:
print(f"🖼️ Starting first_frame generation for shot {first_shot_idx}...")
_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"})
available_image_path_and_text_pairs = []
for character_idx in shot_descriptions[first_shot_idx].ff_vis_char_idxs:
identifier_in_scene = characters[character_idx].identifier_in_scene
registry_item = character_portraits_registry[identifier_in_scene]
for view, item in registry_item.items():
available_image_path_and_text_pairs.append((item["path"], item["description"]))
# generate the first_frame based on the shot_description.ff_desc
if camera.parent_shot_idx is not None:
# generate the first_frame based on the transition video
parent_shot_idx = camera.parent_shot_idx
await self.frame_events[parent_shot_idx]["first_frame"].wait()
parent_shot_ff_path = os.path.join(self.working_dir, "shots", f"{parent_shot_idx}", "first_frame.png")
transition_video_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", f"transition_video_from_shot_{parent_shot_idx}.mp4")
if os.path.exists(transition_video_path):
print(f"🚀 Skipped generating transition video for shot {first_shot_idx} from shot {parent_shot_idx}, already exists.")
_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})
else:
print(f"🖼️ Starting transition video generation for shot {first_shot_idx} from shot {parent_shot_idx}...")
_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})
transition_video_output = await self.camera_image_generator.generate_transition_video(
first_shot_visual_desc=shot_descriptions[parent_shot_idx].visual_desc,
second_shot_visual_desc=shot_descriptions[first_shot_idx].visual_desc,
first_shot_ff_path=parent_shot_ff_path,
progress=_scoped_progress(progress, camera_idx=camera.idx, shot_idx=first_shot_idx, parent_shot_idx=parent_shot_idx, artifact="transition_video"),
)
transition_video_output.save(transition_video_path)
print(f"☑️ Generated transition video for shot {first_shot_idx} from shot {parent_shot_idx}, saved to {transition_video_path}.")
_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})
new_camera_image_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", f"new_camera_{camera.idx}.png")
if os.path.exists(new_camera_image_path):
print(f"🚀 Skipped generating new camera image for shot {first_shot_idx}, already exists.")
_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})
else:
print(f"🖼️ Starting new camera image generation for shot {first_shot_idx}...")
_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})
new_camera_image = self.camera_image_generator.get_new_camera_image(transition_video_path)
new_camera_image.save(new_camera_image_path)
print(f"☑️ Generated new camera image for shot {first_shot_idx} (not completed), saved to {new_camera_image_path}.")
_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})
available_image_path_and_text_pairs.append(
(
new_camera_image_path,
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."
)
)
# 如果子镜头缺少信息,则需要选择参考图像生成
if camera.parent_shot_idx is None or camera.missing_info is not None:
ff_selector_output_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", "first_frame_selector_output.json")
if os.path.exists(ff_selector_output_path):
with open(ff_selector_output_path, 'r', encoding='utf-8') as f:
ff_selector_output = json.load(f)
print(f"🚀 Loaded existing reference image selection and prompt for first_frame of shot {first_shot_idx} from {ff_selector_output_path}.")
_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})
else:
print(f"🔍 Selecting reference images and generating prompt for first_frame of shot {first_shot_idx}...")
_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"})
ff_selector_output = await self.reference_image_selector.select_reference_images_and_generate_prompt(
available_image_path_and_text_pairs=available_image_path_and_text_pairs,
frame_description=shot_descriptions[first_shot_idx].ff_desc
)
with open(ff_selector_output_path, 'w', encoding='utf-8') as f:
json.dump(ff_selector_output, f, ensure_ascii=False, indent=4)
print(f"☑️ Selected reference images and generated prompt for first_frame of shot {first_shot_idx}, saved to {ff_selector_output_path}.")
_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})
reference_image_path_and_text_pairs, prompt = ff_selector_output["reference_image_path_and_text_pairs"], ff_selector_output["text_prompt"]
prefix_prompt = ""
for i, (image_path, text) in enumerate(reference_image_path_and_text_pairs):
prefix_prompt += f"Image {i}: {text}\n"
prompt = f"{prefix_prompt}\n{prompt}"
reference_image_paths = [item[0] for item in reference_image_path_and_text_pairs]
ff_image: ImageOutput = await self.image_generator.generate_single_image(
prompt=prompt,
reference_image_paths=reference_image_paths,
size="1600x900",
)
ff_image.save(first_shot_ff_path)
self.frame_events[first_shot_idx]["first_frame"].set()
print(f"☑️ Generated first_frame for shot {first_shot_idx}, saved to {first_shot_ff_path}.")
_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})
else:
shutil.copy(new_camera_image_path, first_shot_ff_path)
self.frame_events[first_shot_idx]["first_frame"].set()
print(f"☑️ Generated first_frame for shot {first_shot_idx}, saved to {first_shot_ff_path}.")
_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})
# 2. generate the following frames of the camera
priority_tasks = []
normal_tasks = []
if shot_descriptions[first_shot_idx].variation_type in ["medium", "large"]:
task = self.generate_frame_for_single_shot(
shot_idx=first_shot_idx,
frame_type="last_frame",
first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc),
frame_desc=shot_descriptions[first_shot_idx].lf_desc,
visible_characters=[characters[idx] for idx in shot_descriptions[first_shot_idx].lf_vis_char_idxs],
character_portraits_registry=character_portraits_registry,
progress=progress,
)
normal_tasks.append(task)
for shot_idx in camera.active_shot_idxs[1:]:
first_frame_task = self.generate_frame_for_single_shot(
shot_idx=shot_idx,
frame_type="first_frame",
first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc),
frame_desc=shot_descriptions[shot_idx].ff_desc,
visible_characters=[characters[idx] for idx in shot_descriptions[shot_idx].ff_vis_char_idxs],
character_portraits_registry=character_portraits_registry,
progress=progress,
)
if shot_idx in priority_shot_idxs:
priority_tasks.append(first_frame_task)
else:
normal_tasks.append(first_frame_task)
if shot_descriptions[shot_idx].variation_type in ["medium", "large"]:
last_frame_task = self.generate_frame_for_single_shot(
shot_idx=shot_idx,
frame_type="last_frame",
first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc),
frame_desc=shot_descriptions[shot_idx].lf_desc,
visible_characters=[characters[idx] for idx in shot_descriptions[shot_idx].lf_vis_char_idxs],
character_portraits_registry=character_portraits_registry,
progress=progress,
)
normal_tasks.append(last_frame_task)
await asyncio.gather(*priority_tasks)
await asyncio.gather(*normal_tasks)
_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})
async def generate_video_for_single_shot(
self,
shot_description: ShotDescription,
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
):
video_path = os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "video.mp4")
if os.path.exists(video_path):
print(f"🚀 Skipped generating video for shot {shot_description.idx}, already exists.")
_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})
else:
_emit_render_progress(progress, "video_clip_waiting_for_frames", f"Waiting for frames before video clip {shot_description.idx}", {"shot_idx": shot_description.idx})
await self.frame_events[shot_description.idx]["first_frame"].wait()
if shot_description.variation_type in ["medium", "large"]:
await self.frame_events[shot_description.idx]["last_frame"].wait()
frame_paths = []
frame_paths.append(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "first_frame.png"))
if shot_description.variation_type in ["medium", "large"]:
frame_paths.append(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "last_frame.png"))
print(f"🎬 Starting video generation for shot {shot_description.idx}...")
_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)})
video_output = await self.video_generator.generate_single_video(
prompt=shot_description.motion_desc + "\n" + shot_description.audio_desc,
reference_image_paths=frame_paths,
progress=_scoped_progress(progress, shot_idx=shot_description.idx, artifact="video_clip"),
)
video_output.save(video_path)
print(f"☑️ Generated video for shot {shot_description.idx}, saved to {video_path}.")
_emit_render_progress(progress, "video_clip_done", f"Generated video clip for shot {shot_description.idx}", {"shot_idx": shot_description.idx, "path": video_path})
async def generate_frame_for_single_shot(
self,
shot_idx: int,
frame_type: Literal["first_frame", "last_frame"],
first_shot_ff_path_and_text_pair: Tuple[str, str],
frame_desc: str,
visible_characters: List[CharacterInScene],
character_portraits_registry: Dict[str, Dict[str, Dict[str, str]]],
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
) -> ImageOutput:
frame_image_path = os.path.join(self.working_dir, "shots", f"{shot_idx}", f"{frame_type}.png")
if os.path.exists(frame_image_path):
print(f"🚀 Skipped generating {frame_type} for shot {shot_idx}, already exists.")
_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})
else:
print(f"🖼️ Starting {frame_type} generation for shot {shot_idx}...")
_emit_render_progress(progress, "frame_start", f"Generating {frame_type} for shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type})
available_image_path_and_text_pairs = []
for visible_character in visible_characters:
identifier_in_scene = visible_character.identifier_in_scene
registry_item = character_portraits_registry[identifier_in_scene]
for view, item in registry_item.items():
available_image_path_and_text_pairs.append((item["path"], item["description"]))
available_image_path_and_text_pairs.append(first_shot_ff_path_and_text_pair)
selector_output_path = os.path.join(self.working_dir, "shots", f"{shot_idx}", f"{frame_type}_selector_output.json")
if os.path.exists(selector_output_path):
with open(selector_output_path, 'r', encoding='utf-8') as f:
selector_output = json.load(f)
print(f"🚀 Loaded existing reference image selection and prompt for {frame_type} frame of shot {shot_idx} from {selector_output_path}.")
_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})
else:
print(f"🔍 Selecting reference images and generating prompt for {frame_type} frame of shot {shot_idx}...")
_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})
selector_output = await self.reference_image_selector.select_reference_images_and_generate_prompt(
available_image_path_and_text_pairs=available_image_path_and_text_pairs,
frame_description=frame_desc
)
with open(selector_output_path, 'w', encoding='utf-8') as f:
json.dump(selector_output, f, ensure_ascii=False, indent=4)
print(f"☑️ Selected reference images and generated prompt for {frame_type} frame of shot {shot_idx}, saved to {selector_output_path}.")
_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})
reference_image_path_and_text_pairs, prompt = selector_output["reference_image_path_and_text_pairs"], selector_output["text_prompt"]
prefix_prompt = ""
for i, (image_path, text) in enumerate(reference_image_path_and_text_pairs):
prefix_prompt += f"Image {i}: {text}\n"
prompt = f"{prefix_prompt}\n{prompt}"
reference_image_paths = [item[0] for item in reference_image_path_and_text_pairs]
frame_image: ImageOutput = await self.image_generator.generate_single_image(
prompt=prompt,
reference_image_paths=reference_image_paths,
size="1600x900",
)
frame_image.save(frame_image_path)
print(f"☑️ Generated {frame_type} frame for shot {shot_idx}, saved to {frame_image_path}.")
_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})
self.frame_events[shot_idx][frame_type].set()
return frame_image_path
async def construct_camera_tree(
self,
shot_descriptions: List[ShotDescription],
quiet: bool = False,
):
camera_tree_path = os.path.join(self.working_dir, "camera_tree.json")
if os.path.exists(camera_tree_path):
with open(camera_tree_path, "r", encoding="utf-8") as f:
camera_tree = json.load(f)
camera_tree = [Camera.model_validate(camera) for camera in camera_tree]
_pipeline_print(quiet, f"🚀 Loaded {len(camera_tree)} cameras from existing file.")
return camera_tree
shot_descriptions = _normalize_model_list(shot_descriptions, ShotDescription, "shot_descriptions")
cameras = _group_shots_into_cameras(shot_descriptions)
camera_tree = await self.camera_image_generator.construct_camera_tree(cameras=cameras, shot_descs=shot_descriptions)
camera_tree = _normalize_model_list(camera_tree, Camera, "camera_tree")
with open(camera_tree_path, "w", encoding="utf-8") as f:
json.dump([camera.model_dump() for camera in camera_tree], f, ensure_ascii=False, indent=4)
_pipeline_print(quiet, f"✅ Constructed camera tree and saved to {camera_tree_path}.")
return camera_tree
async def extract_characters(
self,
script: str,
quiet: bool = False,
):
save_path = os.path.join(self.working_dir, "characters.json")
if os.path.exists(save_path):
with open(save_path, "r", encoding="utf-8") as f:
characters = json.load(f)
characters = [CharacterInScene.model_validate(character) for character in characters]
_pipeline_print(quiet, f"🚀 Loaded {len(characters)} characters from existing file.")
else:
characters = await self.character_extractor.extract_characters(script)
with open(save_path, "w", encoding="utf-8") as f:
json.dump([character.model_dump() for character in characters], f, ensure_ascii=False, indent=4)
_pipeline_print(quiet, f"✅ Extracted {len(characters)} characters from script and saved to {save_path}.")
for character in characters:
self.character_portrait_events[character.idx] = asyncio.Event()
return characters
async def generate_character_portraits(
self,
characters: List[CharacterInScene],
character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]],
style: str,
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
):
character_portraits_registry_path = os.path.join(self.working_dir, "character_portraits_registry.json")
if character_portraits_registry is None:
if os.path.exists(character_portraits_registry_path):
with open(character_portraits_registry_path, 'r', encoding='utf-8') as f:
character_portraits_registry = json.load(f)
else:
character_portraits_registry = {}
tasks = [
self.generate_portraits_for_single_character(character, style, progress=progress)
for character in characters
if character.identifier_in_scene not in character_portraits_registry
]
if tasks:
for future in asyncio.as_completed(tasks):
character_portraits_registry.update(await future)
with open(character_portraits_registry_path, 'w', encoding='utf-8') as f:
json.dump(character_portraits_registry, f, ensure_ascii=False, indent=4)
print(f"✅ Completed character portrait generation for {len(characters)} characters.")
_emit_render_progress(progress, "character_portraits_done", "Completed character portrait generation", {"character_count": len(characters)})
else:
print("🚀 All characters already have portraits, skipping portrait generation.")
_emit_render_progress(progress, "character_portraits_exist", "All character portraits already exist", {"character_count": len(characters)})
return character_portraits_registry
async def generate_portraits_for_single_character(
self,
character: CharacterInScene,
style: str,
progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
):
character_dir = os.path.join(self.working_dir, "character_portraits", f"{character.idx}_{character.identifier_in_scene}")
os.makedirs(character_dir, exist_ok=True)
_emit_render_progress(progress, "character_portrait_start", f"Generating portraits for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
front_portrait_path = os.path.join(character_dir, "front.png")
if os.path.exists(front_portrait_path):
pass
else:
_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})
front_portrait_output = await self.character_portraits_generator.generate_front_portrait(character, style)
front_portrait_output.save(front_portrait_path)
_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})
side_portrait_path = os.path.join(character_dir, "side.png")
if os.path.exists(side_portrait_path):
pass
else:
_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})
side_portrait_output = await self.character_portraits_generator.generate_side_portrait(character, front_portrait_path)
side_portrait_output.save(side_portrait_path)
_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})
back_portrait_path = os.path.join(character_dir, "back.png")
if os.path.exists(back_portrait_path):
pass
else:
_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})
back_portrait_output = await self.character_portraits_generator.generate_back_portrait(character, front_portrait_path)
back_portrait_output.save(back_portrait_path)
_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})
self.character_portrait_events[character.idx].set()
print(f"☑️ Completed character portrait generation for {character.identifier_in_scene}.")
_emit_render_progress(progress, "character_portrait_done", f"Portraits for {character.identifier_in_scene} ready", {"character_idx": character.idx, "identifier": character.identifier_in_scene})
return {
character.identifier_in_scene: {
"front": {
"path": front_portrait_path,
"description": f"A front view portrait of {character.identifier_in_scene}.",
},
"side": {
"path": side_portrait_path,
"description": f"A side view portrait of {character.identifier_in_scene}.",
},
"back": {
"path": back_portrait_path,
"description": f"A back view portrait of {character.identifier_in_scene}.",
},
}
}
async def design_storyboard(
self,
script: str,
characters: List[CharacterInScene],
user_requirement: str,
quiet: bool = False,
):
storyboard_path = os.path.join(self.working_dir, "storyboard.json")
if os.path.exists(storyboard_path):
with open(storyboard_path, 'r', encoding='utf-8') as f:
storyboard = json.load(f)
storyboard = [ShotBriefDescription.model_validate(shot) for shot in storyboard]
_pipeline_print(quiet, f"🚀 Loaded {len(storyboard)} shot brief descriptions from existing file.")
else:
_pipeline_print(quiet, f"🔍 Designing storyboard...")
storyboard = await self.storyboard_artist.design_storyboard(
script=script,
characters=characters,
user_requirement=user_requirement,
retry_timeout=150,
)
storyboard = _normalize_model_list(storyboard, ShotBriefDescription, "storyboard")
with open(storyboard_path, 'w', encoding='utf-8') as f:
json.dump([shot.model_dump() for shot in storyboard], f, ensure_ascii=False, indent=4)
_pipeline_print(quiet, f"✅ Designed storyboard and saved to {storyboard_path}.")
for shot_brief_description in storyboard:
self.shot_desc_events[shot_brief_description.idx] = asyncio.Event()
return storyboard
async def decompose_visual_descriptions(
self,
shot_brief_descriptions: List[ShotBriefDescription],
characters: List[CharacterInScene],
quiet: bool = False,
):
tasks = [
self.decompose_visual_description_for_single_shot_brief_description(shot_brief_description, characters, quiet=quiet)
for shot_brief_description in shot_brief_descriptions
]
shot_descriptions = await asyncio.gather(*tasks)
return shot_descriptions
async def decompose_visual_description_for_single_shot_brief_description(
self,
shot_brief_description: ShotBriefDescription,
characters: List[CharacterInScene],
quiet: bool = False,
):
shot_description_path = os.path.join(self.working_dir, "shots", f"{shot_brief_description.idx}", "shot_description.json")
os.makedirs(os.path.dirname(shot_description_path), exist_ok=True)
if os.path.exists(shot_description_path):
with open(shot_description_path, 'r', encoding='utf-8') as f:
shot_description = ShotDescription.model_validate(json.load(f))
_pipeline_print(quiet, f"🚀 Loaded shot {shot_brief_description.idx} description from existing file.")
else:
shot_description = await self.storyboard_artist.decompose_visual_description(
shot_brief_desc=shot_brief_description,
characters=characters,
retry_timeout=120,
)
shot_description = _normalize_model_list([shot_description], ShotDescription, "shot_description")[0]
with open(shot_description_path, 'w', encoding='utf-8') as f:
json.dump(shot_description.model_dump(), f, ensure_ascii=False, indent=4)
_pipeline_print(quiet, f"✅ Decomposed visual description for shot {shot_brief_description.idx} and saved to {shot_description_path}.")
self.shot_desc_events[shot_brief_description.idx].set()
if shot_description.variation_type in ["medium", "large"]:
self.frame_events[shot_brief_description.idx] = {
"first_frame": asyncio.Event(),
"last_frame": asyncio.Event(),
}
else:
self.frame_events[shot_brief_description.idx] = {
"first_frame": asyncio.Event(),
}
return shot_description