返回 ViMax
idea2video_pipeline.py
根目录 / pipelines / idea2video_pipeline.py
1 import os
2 import shutil
3 import logging
4 from agents import Screenwriter, CharacterExtractor, CharacterPortraitsGenerator
5 from pipelines.script2video_pipeline import Script2VideoPipeline, _scoped_progress
6 from interfaces import CharacterInScene
7 from typing import Any, Callable, List, Dict, Optional
8 import asyncio
9 import json
10 import yaml
11 from langchain.chat_models import init_chat_model
12 from tools.render_backend import RenderBackend
13 from utils.provider_presets import resolve_chat_model_config
14 from utils.text import safe_path_component
15 from utils.video import concatenate_video_files
16 from utils.image_selection import DEFAULT_IMAGE_CANDIDATES, image_candidate_count_from_config, validate_image_candidate_count
17
18
19 def _pipeline_print(quiet: bool, message: str) -> None:
20 if not quiet:
21 print(message)
22
23
24 class Idea2VideoPipeline:
25 def __init__(
26 self,
27 chat_model: str,
28 image_generator: str,
29 video_generator: str,
30 working_dir: str,
31 num_image_candidates: int = DEFAULT_IMAGE_CANDIDATES,
32 ):
33 self.chat_model = chat_model
34 self.image_generator = image_generator
35 self.video_generator = video_generator
36 self.num_image_candidates = validate_image_candidate_count(num_image_candidates)
37 self.working_dir = working_dir
38 os.makedirs(self.working_dir, exist_ok=True)
39
40 self.screenwriter = Screenwriter(chat_model=self.chat_model)
41 self.character_extractor = CharacterExtractor(
42 chat_model=self.chat_model)
43 self.character_portraits_generator = CharacterPortraitsGenerator(
44 image_generator=self.image_generator)
45
46 @classmethod
47 def init_from_config(cls, config_path: str):
48 with open(config_path, "r") as f:
49 config = yaml.safe_load(f)
50
51 chat_model_args = resolve_chat_model_config(config["chat_model"]["init_args"])
52 chat_model = init_chat_model(**chat_model_args)
53 backend = RenderBackend.from_config(config)
54
55 return cls(
56 chat_model=chat_model,
57 image_generator=backend.image_generator,
58 video_generator=backend.video_generator,
59 working_dir=config["working_dir"],
60 num_image_candidates=image_candidate_count_from_config(config),
61 )
62
63 async def extract_characters(
64 self,
65 story: str,
66 quiet: bool = False,
67 ):
68 save_path = os.path.join(self.working_dir, "characters.json")
69
70 if os.path.exists(save_path):
71 with open(save_path, "r", encoding="utf-8") as f:
72 characters = json.load(f)
73 characters = [CharacterInScene.model_validate(
74 character) for character in characters]
75 _pipeline_print(quiet, f"🚀 Loaded {len(characters)} characters from existing file.")
76 else:
77 characters = await self.character_extractor.extract_characters(story)
78 with open(save_path, "w", encoding="utf-8") as f:
79 json.dump([character.model_dump()
80 for character in characters], f, ensure_ascii=False, indent=4)
81 _pipeline_print(quiet, f"✅ Extracted {len(characters)} characters from story and saved to {save_path}.")
82
83 return characters
84
85 async def generate_character_portraits(
86 self,
87 characters: List[CharacterInScene],
88 character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]],
89 style: str,
90 ):
91 character_portraits_registry_path = os.path.join(
92 self.working_dir, "character_portraits_registry.json")
93 if character_portraits_registry is None:
94 if os.path.exists(character_portraits_registry_path):
95 with open(character_portraits_registry_path, 'r', encoding='utf-8') as f:
96 character_portraits_registry = json.load(f)
97 else:
98 character_portraits_registry = {}
99
100 tasks = [
101 self.generate_portraits_for_single_character(character, style)
102 for character in characters
103 if character.identifier_in_scene not in character_portraits_registry
104 # Characters never shown on screen (e.g. a voice or chat-only
105 # character) have no physical description, so asking the image
106 # model for front/side/back portraits of them is nonsensical and
107 # fails repeatedly (finish_reason=IMAGE_OTHER, empty candidates).
108 and character.is_visible
109 ]
110 if tasks:
111 for future in asyncio.as_completed(tasks):
112 character_portraits_registry.update(await future)
113 with open(character_portraits_registry_path, 'w', encoding='utf-8') as f:
114 json.dump(character_portraits_registry,
115 f, ensure_ascii=False, indent=4)
116
117 print(
118 f"✅ Completed character portrait generation for {len(characters)} characters.")
119 else:
120 print(
121 "🚀 All characters already have portraits, skipping portrait generation.")
122
123 return character_portraits_registry
124
125 async def develop_story(
126 self,
127 idea: str,
128 user_requirement: str,
129 quiet: bool = False,
130 ):
131 save_path = os.path.join(self.working_dir, "story.txt")
132 if os.path.exists(save_path):
133 with open(save_path, "r", encoding="utf-8") as f:
134 story = f.read()
135 _pipeline_print(quiet, f"🚀 Loaded story from existing file.")
136 else:
137 _pipeline_print(quiet, "🧠 Developing story...")
138 story = await self.screenwriter.develop_story(idea=idea, user_requirement=user_requirement)
139 with open(save_path, "w", encoding="utf-8") as f:
140 f.write(story)
141 _pipeline_print(quiet, f"✅ Developed story and saved to {save_path}.")
142
143 return story
144
145 async def write_script_based_on_story(
146 self,
147 story: str,
148 user_requirement: str,
149 quiet: bool = False,
150 ):
151 save_path = os.path.join(self.working_dir, "script.json")
152 if os.path.exists(save_path):
153 with open(save_path, "r", encoding="utf-8") as f:
154 script = json.load(f)
155 _pipeline_print(quiet, f"🚀 Loaded script from existing file.")
156 else:
157 _pipeline_print(quiet, "🧠 Writing script based on story...")
158 script = await self.screenwriter.write_script_based_on_story(story=story, user_requirement=user_requirement)
159 with open(save_path, "w", encoding="utf-8") as f:
160 json.dump(script, f, ensure_ascii=False, indent=4)
161 _pipeline_print(quiet, f"✅ Written script based on story and saved to {save_path}.")
162 return script
163
164 async def generate_portraits_for_single_character(
165 self,
166 character: CharacterInScene,
167 style: str,
168 ):
169 character_dir = os.path.join(
170 self.working_dir, "character_portraits", f"{character.idx}_{safe_path_component(character.identifier_in_scene)}")
171 os.makedirs(character_dir, exist_ok=True)
172
173 front_portrait_path = os.path.join(character_dir, "front.png")
174 if os.path.exists(front_portrait_path):
175 pass
176 else:
177 front_portrait_output = await self.character_portraits_generator.generate_front_portrait(character, style)
178 front_portrait_output.save(front_portrait_path)
179
180 side_portrait_path = os.path.join(character_dir, "side.png")
181 if os.path.exists(side_portrait_path):
182 pass
183 else:
184 try:
185 side_portrait_output = await self.character_portraits_generator.generate_side_portrait(character, front_portrait_path)
186 side_portrait_output.save(side_portrait_path)
187 except Exception as e:
188 # gemini-2.5-flash-image intermittently (sometimes beyond
189 # the tenacity retry budget) fails this front->side
190 # re-angling edit with finish_reason=IMAGE_OTHER / empty
191 # content. Fall back to the front portrait rather than
192 # aborting the whole pipeline.
193 print(f"⚠️ Side portrait generation failed for {character.identifier_in_scene} after retries ({e}); reusing front portrait as fallback.")
194 shutil.copy(front_portrait_path, side_portrait_path)
195
196 back_portrait_path = os.path.join(character_dir, "back.png")
197 if os.path.exists(back_portrait_path):
198 pass
199 else:
200 try:
201 back_portrait_output = await self.character_portraits_generator.generate_back_portrait(character, front_portrait_path)
202 back_portrait_output.save(back_portrait_path)
203 except Exception as e:
204 print(f"⚠️ Back portrait generation failed for {character.identifier_in_scene} after retries ({e}); reusing front portrait as fallback.")
205 shutil.copy(front_portrait_path, back_portrait_path)
206
207 print(
208 f"☑️ Completed character portrait generation for {character.identifier_in_scene}.")
209
210 return {
211 character.identifier_in_scene: {
212 "front": {
213 "path": front_portrait_path,
214 "description": f"A front view portrait of {character.identifier_in_scene}.",
215 },
216 "side": {
217 "path": side_portrait_path,
218 "description": f"A side view portrait of {character.identifier_in_scene}.",
219 },
220 "back": {
221 "path": back_portrait_path,
222 "description": f"A back view portrait of {character.identifier_in_scene}.",
223 },
224 }
225 }
226
227 async def __call__(
228 self,
229 idea: str,
230 user_requirement: str,
231 style: str,
232 quiet: bool = False,
233 progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None,
234 ):
235
236 story = await self.develop_story(idea=idea, user_requirement=user_requirement, quiet=quiet)
237
238 characters = await self.extract_characters(story=story, quiet=quiet)
239
240 character_portraits_registry = await self.generate_character_portraits(
241 characters=characters,
242 character_portraits_registry=None,
243 style=style,
244 )
245
246 scene_scripts = await self.write_script_based_on_story(story=story, user_requirement=user_requirement, quiet=quiet)
247
248 all_video_paths = []
249
250 for idx, scene_script in enumerate(scene_scripts):
251 scene_working_dir = os.path.join(self.working_dir, f"scene_{idx}")
252 os.makedirs(scene_working_dir, exist_ok=True)
253 script2video_pipeline = Script2VideoPipeline(
254 chat_model=self.chat_model,
255 image_generator=self.image_generator,
256 video_generator=self.video_generator,
257 working_dir=scene_working_dir,
258 num_image_candidates=self.num_image_candidates,
259 )
260 final_video_path = await script2video_pipeline(
261 script=scene_script,
262 user_requirement=user_requirement,
263 style=style,
264 characters=characters,
265 character_portraits_registry=character_portraits_registry,
266 quiet=quiet,
267 progress=_scoped_progress(progress, scene_idx=idx),
268 )
269 all_video_paths.append(final_video_path)
270
271 final_video_path = os.path.join(self.working_dir, "final_video.mp4")
272 if os.path.exists(final_video_path):
273 _pipeline_print(quiet, f"🚀 Skipped concatenating videos, already exists.")
274 else:
275 _pipeline_print(quiet, f"🎬 Starting concatenating videos...")
276 concatenate_video_files(all_video_paths, final_video_path)
277 _pipeline_print(quiet, f"☑️ Concatenated videos, saved to {final_video_path}.")
278 return final_video_path
279
279 lines PYTHON