返回 ViMax
vimax_adapters.py
根目录 / agent_runtime / vimax_adapters.py
1 from __future__ import annotations
2
3 import asyncio
4 from datetime import datetime
5 from contextlib import contextmanager, redirect_stderr, redirect_stdout
6 import json
7 import logging
8 import os
9 from pathlib import Path
10 from typing import Any
11
12 from langchain.chat_models import init_chat_model
13 from langchain_openai import OpenAIEmbeddings
14 from tenacity import RetryError
15
16 from interfaces import CharacterInScene
17 from agents.event_extractor import EventExtractor
18 from agents.global_information_planner import GlobalInformationPlanner
19 from agents.novel_compressor import NovelCompressor
20 from agents.scene_extractor import SceneExtractor
21 from pipelines.novel2movie_pipeline import Novel2MoviePipeline
22 from pipelines.idea2video_pipeline import Idea2VideoPipeline
23 from pipelines.script2video_pipeline import Script2VideoPipeline
24 from tools.image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI
25 from tools.image_generator_openrouter_api import ImageGeneratorOpenRouterAPI
26 from tools.reranker_bge_silicon_api import RerankerBgeSiliconapi
27 from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI
28 from tools.video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI
29
30 from .config import api_provider_from_base_url, embedding_api_key, embedding_base_url, embedding_model, embedding_model_provider, image_api_key, image_base_url, image_model, llm_api_key, llm_base_url, llm_model, llm_model_provider, reranker_api_key, reranker_base_url, reranker_model, video_api_key, video_base_url, video_model, video_provider
31 from .models import ToolResult
32 from .tools import ToolArgumentSchema, ToolRuntimeContext, ToolSpec
33
34
35 class _UnavailableGenerator:
36 async def generate_single_image(self, *args: Any, **kwargs: Any) -> Any:
37 raise RuntimeError("Image generator is not available in narrative planning mode")
38
39 async def generate_single_video(self, *args: Any, **kwargs: Any) -> Any:
40 raise RuntimeError("Video generator is not available in narrative planning mode")
41
42
43 def build_vimax_adapter_specs(workspace_root: str | Path, session_index: Any) -> list[ToolSpec]:
44 adapter = ViMaxAdapters(Path(workspace_root), session_index)
45 return [
46 ToolSpec(
47 name="vimax_narrative_planning",
48 description=(
49 "Create or revise ViMax structured text artifacts for the active session. "
50 "Idea mode writes story, characters, script, and scene-level storyboard/shot_decomposition/camera_tree under idea2video/scene_<idx>/. "
51 "Script mode writes characters, storyboard, shot_decomposition, and camera_tree under script2video/. "
52 "Pass the active session_id from prompt context when the user is working in the selected project. An empty active session is initialized in place; a different source on a non-empty session creates a new session instead of overwriting existing artifacts. If idea/script/revision_target are omitted and the active session has an idea, continue that session and fill missing structured text artifacts. "
53 "It does not generate keyframes, video clips, or final video. Call this before revising storyboard/shots when those artifacts do not exist."
54 ),
55 handler=adapter.vimax_narrative_planning,
56 schema={
57 "session_id": ToolArgumentSchema(str, required=False, default=""),
58 "idea": ToolArgumentSchema(str, required=False, default=""),
59 "script": ToolArgumentSchema(str, required=False, default=""),
60 "user_requirement": ToolArgumentSchema(str, required=False, default=""),
61 "style": ToolArgumentSchema(str, required=False, default=""),
62 "revision_target": ToolArgumentSchema(str, required=False, default=""),
63 "revision_instruction": ToolArgumentSchema(str, required=False, default=""),
64 },
65 ),
66 ToolSpec(
67 name="vimax_novel_planning",
68 description=(
69 "Create ViMax structured text artifacts from a novel or novel excerpt. "
70 "This writes novel2video/novel, events, relevant_chunks, scenes, and global_information text artifacts. "
71 "Use this when the user provides long prose, a novel excerpt, or asks for novel-to-video planning. Pass the active session_id when the user is working in a selected empty project. "
72 "It does not generate character portraits, scene videos, or final video."
73 ),
74 handler=adapter.vimax_novel_planning,
75 schema={
76 "session_id": ToolArgumentSchema(str, required=False, default=""),
77 "novel_text": ToolArgumentSchema(str, required=True),
78 "user_requirement": ToolArgumentSchema(str, required=False, default=""),
79 "style": ToolArgumentSchema(str, required=False, default=""),
80 },
81 ),
82 ToolSpec(
83 name="vimax_render_video",
84 description=(
85 "Render keyframes, video clips, and final video for the active ViMax session. "
86 "This checks that structured text artifacts exist before rendering and reports missing dependencies instead of pretending render started."
87 ),
88 handler=adapter.vimax_render_video,
89 schema={
90 "session_id": ToolArgumentSchema(str, required=False, default=""),
91 "mode": ToolArgumentSchema(str, required=False, default="foreground"),
92 "force": ToolArgumentSchema(bool, required=False, default=False),
93 },
94 ),
95 ]
96
97
98 class ViMaxAdapters:
99 def __init__(self, workspace_root: Path, session_index: Any) -> None:
100 self.workspace_root = workspace_root.resolve()
101 self.session_index = session_index
102
103 async def vimax_narrative_planning(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
104 idea = str(args.get("idea", "") or "").strip()
105 script = str(args.get("script", "") or "").strip()
106 user_requirement = str(args.get("user_requirement", "") or "").strip()
107 requested_style = str(args.get("style", "") or "").strip()
108 style = requested_style
109 session = self._resolve_session(str(args.get("session_id", "") or ""), idea=idea, script=script, user_requirement=user_requirement, style=requested_style)
110 session_id = session["session_id"]
111 working_dir = self.session_index.working_dir(session_id)
112 idea_dir = working_dir / "idea2video"
113 script_dir = working_dir / "script2video"
114 idea_dir.mkdir(parents=True, exist_ok=True)
115 script_dir.mkdir(parents=True, exist_ok=True)
116
117 if not idea and not script:
118 revision_target = str(args.get("revision_target") or "").strip()
119 if revision_target:
120 return await self._revise_narrative_artifact(session_id, working_dir, revision_target, str(args.get("revision_instruction") or "").strip(), runtime)
121 session_idea = str(session.get("idea") or "").strip()
122 if session_idea:
123 idea = session_idea
124 user_requirement = user_requirement or str(session.get("user_requirement") or "").strip()
125 style = requested_style or str(session.get("style") or "").strip() or "Cinematic, coherent, 16:9"
126 else:
127 return ToolResult("vimax_narrative_planning", False, "Provide `idea`, `script`, a revision target, or an active session with an existing idea for narrative planning.", {"error_type": "missing_input", "session_id": session_id})
128
129 style = style or str(session.get("style") or "").strip() or "Cinematic, coherent, 16:9"
130 self._update_session_metadata(session_id, idea="", user_requirement="", style=style)
131
132 try:
133 self.session_index.update_stage(session_id, "narrative_planning", "Generating structured text artifacts")
134 if runtime:
135 runtime.emit_progress("Starting narrative planning", stage="starting", metadata={"session_id": session_id})
136 await asyncio.sleep(0)
137 generated_before = self.session_index.artifact_checklist(session_id)
138 if runtime:
139 runtime.emit_progress("Initializing bounded chat model", stage="initializing_llm", metadata={"session_id": session_id, "timeout_seconds": _llm_request_timeout_seconds(), "max_tokens": _narrative_max_tokens()})
140 await asyncio.sleep(0)
141 chat_model = _build_chat_model()
142 if runtime:
143 runtime.emit_progress("Bounded chat model initialized", stage="chat_model_ready", metadata={"session_id": session_id})
144 await asyncio.sleep(0)
145 dummy = _UnavailableGenerator()
146 # Do not globally redirect stdout/stderr while the JSONL CLI is streaming events.
147 # The adapter exposes pipeline progress through explicit tool_progress events instead.
148 if idea:
149 idea_pipeline = Idea2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(idea_dir))
150 if runtime:
151 runtime.emit_progress("Idea pipeline initialized", stage="idea_pipeline_ready", metadata={"session_id": session_id})
152 await asyncio.sleep(0)
153 story = await _run_planning_step(
154 "Developing story from user idea",
155 "develop_story",
156 idea_pipeline.develop_story(idea=idea, user_requirement=user_requirement, quiet=True),
157 runtime,
158 {"session_id": session_id},
159 )
160 characters = await _run_planning_step(
161 "Extracting characters from story",
162 "extract_characters",
163 idea_pipeline.extract_characters(story=story, quiet=True),
164 runtime,
165 {"session_id": session_id},
166 )
167 scene_scripts = await _run_planning_step(
168 "Writing scene scripts from story",
169 "write_script",
170 idea_pipeline.write_script_based_on_story(story=story, user_requirement=user_requirement, quiet=True),
171 runtime,
172 {"session_id": session_id},
173 )
174 for idx, scene_script in enumerate(scene_scripts if isinstance(scene_scripts, list) else [scene_scripts]):
175 scene_dir = idea_dir / f"scene_{idx}"
176 scene_text = scene_script if isinstance(scene_script, str) else json.dumps(scene_script, ensure_ascii=False, indent=2)
177 script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(scene_dir))
178 await _run_planning_step(
179 f"Planning scene {idx} storyboard and shots",
180 "plan_scene",
181 script_pipeline.plan_text_artifacts(script=scene_text, user_requirement=user_requirement, style=style, characters=characters, progress=_pipeline_progress(runtime, session_id, scene_index=idx), quiet=True),
182 runtime,
183 {"session_id": session_id, "scene_index": idx},
184 )
185 else:
186 (script_dir / "script.txt").write_text(script, encoding="utf-8")
187 script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(script_dir))
188 if runtime:
189 runtime.emit_progress("Script pipeline initialized", stage="script_pipeline_ready", metadata={"session_id": session_id})
190 await asyncio.sleep(0)
191 await _run_planning_step(
192 "Planning storyboard and shots from provided script",
193 "plan_script",
194 script_pipeline.plan_text_artifacts(script=script, user_requirement=user_requirement, style=style, progress=_pipeline_progress(runtime, session_id), quiet=True),
195 runtime,
196 {"session_id": session_id},
197 )
198 except Exception as exc:
199 self.session_index.update_stage(session_id, "error", f"Narrative planning failed: {exc}")
200 checklist = self.session_index.artifact_checklist(session_id)
201 payload = {
202 "session_id": session_id,
203 "working_dir": str(working_dir.relative_to(self.workspace_root)),
204 "error_type": "recoverable_planning_step_failed",
205 "retryable": True,
206 "error": str(exc),
207 "present": [path for path, present in checklist.items() if present],
208 "missing": [path for path, present in checklist.items() if not present],
209 }
210 if runtime:
211 runtime.emit_progress("Narrative planning failed; partial artifacts were kept", stage="planning_failed", metadata=payload)
212 return ToolResult("vimax_narrative_planning", False, f"Narrative planning failed: {exc}", payload)
213
214 checklist = self.session_index.artifact_checklist(session_id)
215 generated = [path for path, present in checklist.items() if present and not generated_before.get(path)]
216 reused = [path for path, present in checklist.items() if present and generated_before.get(path)]
217 ready_for_render = _ready_for_render(checklist)
218 self.session_index.update_stage(session_id, "narrative_planned", "Structured text planning complete" if ready_for_render else "Structured text planning partially complete")
219 if runtime:
220 runtime.emit_progress("Narrative planning complete", stage="completed", metadata={"ready_for_render": ready_for_render})
221 payload = {
222 "session_id": session_id,
223 "working_dir": str(working_dir.relative_to(self.workspace_root)),
224 "generated": generated,
225 "reused": reused,
226 "missing": [path for path, present in checklist.items() if not present],
227 "ready_for_render": ready_for_render,
228 }
229 return ToolResult("vimax_narrative_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
230
231 async def _revise_narrative_artifact(self, session_id: str, working_dir: Path, revision_target: str, revision_instruction: str, runtime: ToolRuntimeContext | None = None) -> ToolResult:
232 if not revision_instruction:
233 self.session_index.update_stage(session_id, "error", "Revision failed: missing revision_instruction")
234 return ToolResult("vimax_narrative_planning", False, "revision_instruction is required when revision_target is provided.", {"error_type": "missing_revision_instruction", "session_id": session_id, "revision_target": revision_target})
235 try:
236 target_path = _resolve_artifact_path(working_dir, revision_target)
237 except ValueError as exc:
238 self.session_index.update_stage(session_id, "error", f"Revision failed: {exc}")
239 return ToolResult("vimax_narrative_planning", False, str(exc), {"error_type": "invalid_revision_target", "session_id": session_id, "revision_target": revision_target})
240 if not target_path.exists():
241 self.session_index.update_stage(session_id, "error", f"Revision failed: target does not exist: {revision_target}")
242 return ToolResult("vimax_narrative_planning", False, f"Revision target does not exist: {revision_target}", {"error_type": "dependency_missing", "session_id": session_id, "revision_target": revision_target})
243 try:
244 self.session_index.update_stage(session_id, "narrative_planning", "Revising structured text artifact")
245 if runtime:
246 runtime.emit_progress("Revising structured text artifact", stage="revising", metadata={"session_id": session_id, "revision_target": revision_target})
247 chat_model = _build_chat_model()
248 before = target_path.read_text(encoding="utf-8")
249 revised = await _revise_artifact_with_llm(chat_model, target_path.relative_to(working_dir).as_posix(), before, revision_instruction)
250 if target_path.suffix == ".json":
251 try:
252 revised_payload = json.loads(revised)
253 except json.JSONDecodeError as exc:
254 self.session_index.update_stage(session_id, "error", f"Revision failed: invalid JSON output: {exc}")
255 return ToolResult("vimax_narrative_planning", False, f"Revision output was not valid JSON: {exc}", {"error_type": "invalid_revision_json", "session_id": session_id, "revision_target": revision_target})
256 revised = json.dumps(revised_payload, ensure_ascii=False, indent=2)
257 target_path.write_text(revised, encoding="utf-8")
258 except Exception as exc:
259 self.session_index.update_stage(session_id, "error", f"Revision failed: {exc}")
260 raise
261
262 stale = _stale_keys_for_revision(target_path.relative_to(working_dir).as_posix())
263 if stale:
264 self.session_index.mark_stale(session_id, stale)
265 self.session_index.append_log("revisions", {"session_id": session_id, "target": target_path.relative_to(working_dir).as_posix(), "instruction": revision_instruction, "stale": stale, "before_preview": before[:500], "after_preview": revised[:500]})
266 checklist = self.session_index.artifact_checklist(session_id)
267 ready_for_render = _ready_for_render(checklist)
268 self.session_index.update_stage(session_id, "narrative_planned" if ready_for_render else "narrative_planning", "Revised structured text artifact")
269 payload = {
270 "session_id": session_id,
271 "working_dir": str(working_dir.relative_to(self.workspace_root)),
272 "generated": [],
273 "reused": [path for path, present in checklist.items() if present],
274 "revised": [target_path.relative_to(working_dir).as_posix()],
275 "missing": [path for path, present in checklist.items() if not present],
276 "stale": stale,
277 "ready_for_render": ready_for_render,
278 "revision_target": target_path.relative_to(working_dir).as_posix(),
279 }
280 return ToolResult("vimax_narrative_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
281
282 async def vimax_novel_planning(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
283 novel_text = str(args.get("novel_text", "") or "").strip()
284 user_requirement = str(args.get("user_requirement", "") or "").strip()
285 style = str(args.get("style", "") or "").strip() or "Cinematic, coherent, 16:9"
286 if not novel_text:
287 return ToolResult("vimax_novel_planning", False, "novel_text is required for novel planning.", {"error_type": "missing_input"})
288
289 session_id_arg = str(args.get("session_id", "") or "").strip()
290 session = self._resolve_session(session_id_arg, idea=novel_text, script="", user_requirement=user_requirement, style=style)
291 session_id = session["session_id"]
292 working_dir = self.session_index.working_dir(session_id)
293 novel_dir = working_dir / "novel2video"
294 novel_dir.mkdir(parents=True, exist_ok=True)
295 generated_before = self.session_index.artifact_checklist(session_id)
296
297 try:
298 self.session_index.update_stage(session_id, "novel_planning", "Generating novel structured text artifacts")
299 if runtime:
300 runtime.emit_progress("Starting novel planning", stage="starting", metadata={"session_id": session_id})
301 await asyncio.sleep(0)
302 pipeline = _build_novel_pipeline(novel_dir)
303 await _run_planning_step(
304 "Planning novel structured text artifacts",
305 "novel_plan_text_artifacts",
306 pipeline.plan_text_artifacts(
307 novel_text=novel_text,
308 user_requirement=user_requirement,
309 style=style,
310 progress=_pipeline_progress(runtime, session_id),
311 quiet=True,
312 ),
313 runtime,
314 {"session_id": session_id},
315 )
316 except Exception as exc:
317 self.session_index.update_stage(session_id, "error", f"Novel planning failed: {exc}")
318 return ToolResult("vimax_novel_planning", False, str(exc), {"error_type": "exception", "session_id": session_id})
319
320 checklist = self.session_index.artifact_checklist(session_id)
321 generated = [path for path, present in checklist.items() if path.startswith("novel2video/") and present and not generated_before.get(path)]
322 reused = [path for path, present in checklist.items() if path.startswith("novel2video/") and present and generated_before.get(path)]
323 missing = [path for path, present in checklist.items() if path.startswith("novel2video/") and not present]
324 ready = _novel_text_ready(checklist)
325 self.session_index.update_stage(session_id, "novel_planned" if ready else "novel_planning", "Novel structured text planning complete" if ready else "Novel structured text planning partially complete")
326 if runtime:
327 runtime.emit_progress("Novel planning complete", stage="completed", metadata={"session_id": session_id, "ready_for_scene_render": False})
328 payload = {
329 "session_id": session_id,
330 "working_dir": str(working_dir.relative_to(self.workspace_root)),
331 "generated": generated,
332 "reused": reused,
333 "missing": missing,
334 "ready_for_scene_render": False,
335 }
336 return ToolResult("vimax_novel_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
337
338 async def vimax_render_video(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
339 session_id = str(args.get("session_id", "") or "").strip()
340 session = self.session_index.get(session_id) if session_id else self.session_index.active()
341 if session is None:
342 return ToolResult("vimax_render_video", False, "No active session to render.", {"error_type": "missing_session"})
343 session_id = session["session_id"]
344 checklist = self.session_index.artifact_checklist(session_id)
345 missing = _missing_render_dependencies(checklist)
346 working_dir = self.session_index.working_dir(session_id)
347 if missing:
348 payload = {"error_type": "dependency_missing", "missing": missing, "session_id": session_id}
349 _write_render_status(working_dir, status="dependency_missing", payload=payload)
350 return ToolResult("vimax_render_video", False, f"Dependency missing: {', '.join(missing)}", payload)
351
352 self.session_index.update_stage(session_id, "rendering", "Rendering video artifacts")
353 _write_render_status(working_dir, status="rendering", payload={"session_id": session_id, "render_started": True, "render_completed": False})
354 try:
355 chat_model = _build_chat_model()
356 image_generator = _build_image_generator()
357 video_generator = _build_video_generator()
358 if runtime:
359 runtime.emit_progress("Starting video render", stage="rendering", metadata={"session_id": session_id})
360 if _idea_mode_ready(checklist):
361 idea_pipeline = Idea2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(working_dir / "idea2video"))
362 with _suppress_pipeline_output():
363 final_video = await idea_pipeline(idea=str(session.get("idea", "")), user_requirement=str(session.get("user_requirement", "")), style=str(session.get("style", "")), quiet=True)
364 self.session_index.update_stage(session_id, "rendered", "Final video rendered")
365 payload = {"session_id": session_id, "render_mode": "idea2video", "render_started": True, "render_completed": True, "final_video_path": str(Path(final_video).relative_to(self.workspace_root)), "missing": []}
366 _write_render_status(working_dir, status="rendered", payload=payload)
367 return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
368 if _script_mode_ready(checklist):
369 script_dir = working_dir / "script2video"
370 script_text = _load_script_text(working_dir)
371 characters = _load_characters(script_dir / "characters.json")
372 pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(script_dir))
373 with _suppress_pipeline_output():
374 final_video = await pipeline(script=script_text, user_requirement=str(session.get("user_requirement", "")), style=str(session.get("style", "")), characters=characters, quiet=True, progress=_pipeline_progress(runtime, session_id))
375 self.session_index.update_stage(session_id, "rendered", "Final video rendered")
376 payload = {"session_id": session_id, "render_mode": "script2video", "render_started": True, "render_completed": True, "final_video_path": str(Path(final_video).relative_to(self.workspace_root)), "missing": []}
377 _write_render_status(working_dir, status="rendered", payload=payload)
378 return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
379 if _novel_mode_ready(checklist):
380 novel_dir = working_dir / "novel2video"
381 pipeline = _build_novel_render_pipeline(novel_dir, chat_model, image_generator, video_generator)
382 with _suppress_pipeline_output():
383 render_result = await pipeline.render_video_artifacts(style=str(session.get("style", "")), user_requirement=str(session.get("user_requirement", "")), quiet=True, progress=_pipeline_progress(runtime, session_id))
384 scene_videos_dir = Path(render_result["scene_videos_dir"])
385 self.session_index.update_stage(session_id, "novel_scene_rendered", "Novel scene videos rendered")
386 payload = {
387 "session_id": session_id,
388 "render_mode": "novel2video",
389 "render_started": True,
390 "render_completed": True,
391 "scene_render_completed": True,
392 "final_video_path": None,
393 "scene_videos_dir": str(scene_videos_dir.relative_to(self.workspace_root)),
394 "scene_video_dirs": [str(Path(path).relative_to(self.workspace_root)) for path in render_result.get("scene_video_dirs", [])],
395 "scene_count": render_result.get("scene_count", 0),
396 "missing": [],
397 }
398 _write_render_status(working_dir, status="rendered", payload=payload)
399 return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
400 except Exception as exc:
401 unwrapped = _unwrap_retry_error(exc)
402 error_text = _sanitize_error_text(str(unwrapped))
403 wrapped_error_text = _sanitize_error_text(str(exc))
404 self.session_index.update_stage(session_id, "error", f"Render failed: {error_text}")
405 checklist = self.session_index.artifact_checklist(session_id)
406 payload = {
407 "error_type": "render_failed",
408 "retryable": _is_retryable_render_error(unwrapped),
409 "session_id": session_id,
410 "error": error_text,
411 "wrapped_error": wrapped_error_text,
412 "present": [path for path, present in checklist.items() if present],
413 "missing": [path for path, present in checklist.items() if not present],
414 }
415 _write_render_status(working_dir, status="error", payload=payload)
416 if runtime:
417 runtime.emit_progress("Render failed; partial artifacts were kept", stage="render_failed", metadata=payload)
418 return ToolResult("vimax_render_video", False, f"Render failed: {error_text}", payload)
419 payload = {"error_type": "dependency_missing", "session_id": session_id}
420 _write_render_status(working_dir, status="dependency_missing", payload=payload)
421 return ToolResult("vimax_render_video", False, "No render mode matched current session.", payload)
422
423 def _resolve_session(self, session_id: str, *, idea: str, script: str, user_requirement: str, style: str) -> dict[str, Any]:
424 requested_source = idea or script
425 if session_id:
426 session = self.session_index.get(session_id)
427 if session is None:
428 session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style, session_id=session_id)
429 elif requested_source and _is_new_source_for_session(session, requested_source):
430 session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style)
431 else:
432 self.session_index.set_active(session_id)
433 else:
434 if requested_source:
435 active = self.session_index.active()
436 if active is not None and self._session_is_empty(active):
437 session = self.session_index.set_active(active["session_id"])
438 else:
439 session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style)
440 else:
441 session = self.session_index.active() or self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style)
442 self._update_session_metadata(session["session_id"], idea=requested_source, user_requirement=user_requirement, style=style)
443 return self.session_index.get(session["session_id"]) or session
444
445 def _session_is_empty(self, session: dict[str, Any]) -> bool:
446 if str(session.get("idea") or "").strip():
447 return False
448 session_id = str(session.get("session_id") or "").strip()
449 if not session_id:
450 return False
451 return not any(self.session_index.artifact_checklist(session_id).values())
452
453 def _update_session_metadata(self, session_id: str, *, idea: str, user_requirement: str, style: str) -> None:
454 data = self.session_index.load()
455 record = data.get("sessions", {}).get(session_id)
456 if not isinstance(record, dict):
457 return
458 if idea and not record.get("idea"):
459 record["idea"] = idea
460 if user_requirement:
461 record["user_requirement"] = user_requirement
462 if style:
463 record["style"] = style
464 self.session_index.save(data)
465
466
467 class _DiscardStream:
468 def write(self, text: str) -> int:
469 return len(text)
470
471 def flush(self) -> None:
472 pass
473
474
475 _PIPELINE_OUTPUT_SINK = _DiscardStream()
476
477
478 @contextmanager
479 def _suppress_pipeline_output():
480 previous_disable_level = logging.root.manager.disable
481 logging.disable(logging.WARNING)
482 try:
483 with redirect_stdout(_PIPELINE_OUTPUT_SINK), redirect_stderr(_PIPELINE_OUTPUT_SINK):
484 yield
485 finally:
486 logging.disable(previous_disable_level)
487
488
489 def _narrative_step_timeout_seconds() -> float:
490 raw = os.environ.get("VIMAX_NARRATIVE_STEP_TIMEOUT_SECONDS", "900")
491 try:
492 return max(0.0, float(raw))
493 except ValueError:
494 return 900.0
495
496
497 async def _run_planning_step(
498 message: str,
499 stage: str,
500 awaitable: Any,
501 runtime: ToolRuntimeContext | None,
502 metadata: dict[str, Any] | None = None,
503 ) -> Any:
504 timeout_seconds = _narrative_step_timeout_seconds()
505 event_metadata = dict(metadata or {})
506 event_metadata["timeout_seconds"] = timeout_seconds
507 if runtime:
508 runtime.emit_progress(message, stage=stage, metadata=event_metadata)
509 await asyncio.sleep(0)
510 try:
511 with _suppress_pipeline_output():
512 if timeout_seconds <= 0:
513 return await awaitable
514 return await asyncio.wait_for(awaitable, timeout=timeout_seconds)
515 except asyncio.TimeoutError as exc:
516 raise RuntimeError(f"{message} timed out after {timeout_seconds:g}s") from exc
517 except Exception as exc:
518 raise RuntimeError(f"{message} failed: {exc}") from exc
519
520
521 def _is_new_source_for_session(session: dict[str, Any], requested_source: str) -> bool:
522 current = str(session.get("idea") or "").strip()
523 requested = requested_source.strip()
524 if not current or not requested:
525 return False
526 return current != requested
527
528
529 def _llm_request_timeout_seconds() -> float:
530 raw = os.environ.get("VIMAX_LLM_REQUEST_TIMEOUT_SECONDS", "300")
531 try:
532 return max(1.0, float(raw))
533 except ValueError:
534 return 300.0
535
536
537 def _narrative_max_tokens() -> int:
538 raw = os.environ.get("VIMAX_NARRATIVE_MAX_TOKENS", "4096")
539 try:
540 return max(256, int(raw))
541 except ValueError:
542 return 4096
543
544
545 def _pipeline_progress(runtime: ToolRuntimeContext | None, session_id: str, *, scene_index: int | None = None):
546 if runtime is None:
547 return None
548
549 def emit(stage: str, message: str, metadata: dict[str, Any] | None = None) -> None:
550 payload = dict(metadata or {})
551 payload["session_id"] = session_id
552 if scene_index is not None:
553 payload["scene_index"] = scene_index
554 runtime.emit_progress(message, stage=stage, metadata=payload)
555
556 return emit
557
558
559 def _build_chat_model() -> Any:
560 api_key = llm_api_key()
561 if not api_key:
562 raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for narrative planning")
563 return init_chat_model(
564 model=llm_model(),
565 model_provider=llm_model_provider(),
566 api_key=api_key,
567 base_url=llm_base_url(),
568 timeout=_llm_request_timeout_seconds(),
569 max_retries=0,
570 max_completion_tokens=_narrative_max_tokens(),
571 )
572
573
574 def _build_image_generator() -> ImageGeneratorNanobananaYunwuAPI | ImageGeneratorOpenRouterAPI:
575 api_key = image_api_key()
576 if not api_key:
577 raise RuntimeError("VIMAX_IMAGE_API_KEY, VIMAX_LLM_API_KEY, or configs/agent.local.yaml image/llm api_key is required for image generation")
578 model = image_model()
579 base_url = image_base_url()
580 if api_provider_from_base_url(base_url) == "openrouter":
581 return ImageGeneratorOpenRouterAPI(api_key=api_key, model=model, base_url=base_url)
582 return ImageGeneratorNanobananaYunwuAPI(api_key=api_key, model=model, base_url=base_url)
583
584
585 def _build_video_generator() -> VideoGeneratorVeoYunwuAPI | VideoGeneratorOpenRouterAPI:
586 api_key = video_api_key()
587 if not api_key:
588 raise RuntimeError("VIMAX_VIDEO_API_KEY, VIMAX_LLM_API_KEY, or configs/agent.local.yaml video/llm api_key is required for video generation")
589 model = video_model()
590 base_url = video_base_url()
591 provider = video_provider().strip().lower()
592 if provider == "openrouter":
593 return VideoGeneratorOpenRouterAPI(api_key=api_key, model=model, base_url=base_url)
594 if provider == "yunwu":
595 return VideoGeneratorVeoYunwuAPI(api_key=api_key, t2v_model=model, ff2v_model=model, base_url=base_url)
596 raise RuntimeError(f"Unsupported video base_url for automatic provider matching: {base_url}")
597
598
599 class _IdentityRewriter:
600 async def __call__(self, prompt: str) -> str:
601 return prompt
602
603
604 def _build_embedding_model() -> Any:
605 api_key = embedding_api_key()
606 base_url = embedding_base_url()
607 provider = embedding_model_provider().strip().lower()
608 if not api_key or not base_url:
609 raise RuntimeError("VIMAX_EMBEDDING_API_KEY or configs/agent.local.yaml embedding api_key/base_url is required for novel planning")
610 if provider != "openai":
611 raise RuntimeError(f"Unsupported embedding model_provider: {provider}")
612 return OpenAIEmbeddings(model=embedding_model(), api_key=api_key, base_url=base_url)
613
614
615 def _build_reranker() -> RerankerBgeSiliconapi:
616 api_key = reranker_api_key()
617 base_url = reranker_base_url()
618 if not api_key or not base_url:
619 raise RuntimeError("VIMAX_RERANKER_API_KEY or configs/agent.local.yaml reranker api_key/base_url is required for novel planning")
620 return RerankerBgeSiliconapi(api_key=api_key, base_url=base_url, model=reranker_model())
621
622
623 def _build_novel_pipeline(working_dir: Path) -> Novel2MoviePipeline:
624 api_key = llm_api_key()
625 if not api_key:
626 raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for novel planning")
627 base_url = llm_base_url()
628 model = llm_model()
629 dummy = _UnavailableGenerator()
630 return Novel2MoviePipeline(
631 novel_compressor=NovelCompressor(api_key=api_key, base_url=base_url, chat_model=model),
632 event_extractor=EventExtractor(api_key=api_key, base_url=base_url, chat_model=model),
633 embeddings=_build_embedding_model(),
634 rerank_model=_build_reranker(),
635 scene_extractor=SceneExtractor(api_key=api_key, base_url=base_url, chat_model=model),
636 global_information_planner=GlobalInformationPlanner(api_key=api_key, base_url=base_url, chat_model=model),
637 image_generator=dummy,
638 rewriter=_IdentityRewriter(),
639 script2video_pipeline=dummy,
640 working_dir=str(working_dir),
641 )
642
643
644 def _build_novel_render_pipeline(working_dir: Path, chat_model: Any, image_generator: Any, video_generator: Any) -> Novel2MoviePipeline:
645 api_key = llm_api_key()
646 if not api_key:
647 raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for novel rendering")
648 base_url = llm_base_url()
649 model = llm_model()
650 script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(working_dir / "videos"))
651 return Novel2MoviePipeline(
652 novel_compressor=NovelCompressor(api_key=api_key, base_url=base_url, chat_model=model),
653 event_extractor=EventExtractor(api_key=api_key, base_url=base_url, chat_model=model),
654 embeddings=_build_embedding_model(),
655 rerank_model=_build_reranker(),
656 scene_extractor=SceneExtractor(api_key=api_key, base_url=base_url, chat_model=model),
657 global_information_planner=GlobalInformationPlanner(api_key=api_key, base_url=base_url, chat_model=model),
658 image_generator=image_generator,
659 rewriter=_IdentityRewriter(),
660 script2video_pipeline=script_pipeline,
661 working_dir=str(working_dir),
662 )
663
664
665 def _unwrap_retry_error(exc: Exception) -> Exception:
666 if isinstance(exc, RetryError):
667 try:
668 return exc.last_attempt.exception() or exc
669 except Exception:
670 return exc
671 return exc
672
673
674 def _is_retryable_render_error(exc: Exception) -> bool:
675 text = str(exc).lower()
676 if isinstance(exc, AttributeError):
677 return False
678 if "http 403" in text or "key limit exceeded" in text or "quota" in text:
679 return False
680 return True
681
682
683 def _sanitize_error_text(text: str) -> str:
684 sanitized = text
685 for marker in ("workspaces/default/keys/",):
686 if marker in sanitized:
687 prefix, rest = sanitized.split(marker, 1)
688 key_id = []
689 for char in rest:
690 if char.isalnum() or char in "-_":
691 key_id.append(char)
692 continue
693 break
694 sanitized = prefix + marker + "<redacted>" + rest[len(key_id):]
695 if "sk-" in sanitized:
696 prefix, rest = sanitized.split("sk-", 1)
697 token = []
698 for char in rest:
699 if char.isalnum() or char in "-_":
700 token.append(char)
701 continue
702 break
703 sanitized = prefix + "sk-<redacted>" + rest[len(token):]
704 return sanitized
705
706
707 def _write_render_status(working_dir: Path, *, status: str, payload: dict[str, Any]) -> None:
708 working_dir.mkdir(parents=True, exist_ok=True)
709 event = {
710 "timestamp": datetime.now().isoformat(timespec="seconds"),
711 "status": status,
712 **payload,
713 }
714 (working_dir / "render_status.json").write_text(json.dumps(event, ensure_ascii=False, indent=2), encoding="utf-8")
715 with (working_dir / "render_events.jsonl").open("a", encoding="utf-8") as handle:
716 handle.write(json.dumps(event, ensure_ascii=False) + "\n")
717
718
719 def _write_characters_if_missing(path: Path, characters: list[CharacterInScene]) -> None:
720 if path.exists():
721 return
722 path.parent.mkdir(parents=True, exist_ok=True)
723 path.write_text(json.dumps([character.model_dump() for character in characters], ensure_ascii=False, indent=2), encoding="utf-8")
724
725
726 def _load_characters(path: Path) -> list[CharacterInScene]:
727 return [CharacterInScene.model_validate(item) for item in json.loads(path.read_text(encoding="utf-8"))]
728
729
730 def _load_script_text(working_dir: Path) -> str:
731 script_text = working_dir / "script2video" / "script.txt"
732 if script_text.exists():
733 return script_text.read_text(encoding="utf-8")
734 idea_script = working_dir / "idea2video" / "script.json"
735 if idea_script.exists():
736 payload = json.loads(idea_script.read_text(encoding="utf-8"))
737 return json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload
738 story = working_dir / "idea2video" / "story.txt"
739 if story.exists():
740 return story.read_text(encoding="utf-8")
741 return ""
742
743
744 def _resolve_artifact_path(working_dir: Path, revision_target: str) -> Path:
745 rel = Path(revision_target)
746 if rel.is_absolute():
747 raise ValueError(f"revision_target must be relative to session working_dir: {revision_target}")
748 path = (working_dir / rel).resolve()
749 if path != working_dir and working_dir not in path.parents:
750 raise ValueError(f"revision_target escapes session working_dir: {revision_target}")
751 return path
752
753
754 async def _revise_artifact_with_llm(chat_model: Any, target: str, current_text: str, instruction: str) -> str:
755 prompt = (
756 "Revise this ViMax structured artifact exactly as requested. "
757 "Return only the complete replacement file content, with no Markdown fences or explanation. "
758 "If the file is JSON, preserve valid JSON and the existing schema shape.\n\n"
759 f"Target: {target}\n"
760 f"Revision instruction: {instruction}\n\n"
761 "Current file content:\n"
762 f"{current_text}"
763 )
764 if hasattr(chat_model, "ainvoke"):
765 response = await chat_model.ainvoke(prompt)
766 elif hasattr(chat_model, "invoke"):
767 response = chat_model.invoke(prompt)
768 else:
769 raise RuntimeError("chat_model does not support invoke/ainvoke for revision mode")
770 content = getattr(response, "content", response)
771 if isinstance(content, list):
772 content = "".join(str(item.get("text", item)) if isinstance(item, dict) else str(item) for item in content)
773 return _strip_markdown_fences(str(content).strip())
774
775
776 def _strip_markdown_fences(text: str) -> str:
777 if not text.startswith("```"):
778 return text
779 lines = text.splitlines()
780 if lines and lines[0].startswith("```"):
781 lines = lines[1:]
782 if lines and lines[-1].strip() == "```":
783 lines = lines[:-1]
784 return "\n".join(lines).strip()
785
786
787 def _stale_keys_for_revision(target: str) -> list[str]:
788 if "storyboard.json" in target:
789 return ["shot_descriptions", "camera_tree", "frames", "clips", "final_video"]
790 if "shot_description.json" in target:
791 return ["frames", "clips", "final_video"]
792 if "camera_tree.json" in target:
793 return ["frames", "clips", "final_video"]
794 if target.endswith("script.json") or target.endswith("story.txt"):
795 return ["storyboard", "shot_descriptions", "camera_tree", "frames", "clips", "final_video"]
796 if target.endswith("characters.json"):
797 return ["storyboard", "shot_descriptions", "frames", "clips", "final_video"]
798 return ["frames", "clips", "final_video"]
799
800
801 def _ready_for_render(checklist: dict[str, bool]) -> bool:
802 return _idea_mode_ready(checklist) or _script_mode_ready(checklist) or _novel_mode_ready(checklist)
803
804
805 def _missing_render_dependencies(checklist: dict[str, bool]) -> list[str]:
806 if _ready_for_render(checklist):
807 return []
808 idea_required = ["idea2video/story.txt", "idea2video/characters.json", "idea2video/script.json", "idea2video/scene_*/storyboard.json", "idea2video/scene_*/shots/*/shot_description.json", "idea2video/scene_*/camera_tree.json"]
809 script_required = ["script2video/script.txt", "script2video/characters.json", "script2video/storyboard.json", "script2video/shots/*/shot_description.json", "script2video/camera_tree.json"]
810 novel_required = ["novel2video/novel/novel_compressed.txt", "novel2video/events/event_*.json", "novel2video/relevant_chunks/event_*", "novel2video/scenes/event_*/scene_*.json", "novel2video/global_information/characters/event_level/*.json", "novel2video/global_information/characters/novel_level/*.json"]
811 return [f"idea mode: {path}" for path in idea_required if not checklist.get(path)] + [f"script mode: {path}" for path in script_required if not checklist.get(path)] + [f"novel mode: {path}" for path in novel_required if not checklist.get(path)]
812
813
814 def _idea_mode_ready(checklist: dict[str, bool]) -> bool:
815 return bool(checklist.get("idea2video/story.txt") and checklist.get("idea2video/characters.json") and checklist.get("idea2video/script.json") and checklist.get("idea2video/scene_*/storyboard.json") and checklist.get("idea2video/scene_*/shots/*/shot_description.json") and checklist.get("idea2video/scene_*/camera_tree.json"))
816
817
818 def _novel_text_ready(checklist: dict[str, bool]) -> bool:
819 return _novel_mode_ready(checklist)
820
821
822 def _novel_mode_ready(checklist: dict[str, bool]) -> bool:
823 return bool(checklist.get("novel2video/novel/novel_compressed.txt") and checklist.get("novel2video/events/event_*.json") and checklist.get("novel2video/relevant_chunks/event_*") and checklist.get("novel2video/scenes/event_*/scene_*.json") and checklist.get("novel2video/global_information/characters/event_level/*.json") and checklist.get("novel2video/global_information/characters/novel_level/*.json"))
824
825
826 def _script_mode_ready(checklist: dict[str, bool]) -> bool:
827 return bool(checklist.get("script2video/script.txt") and checklist.get("script2video/characters.json") and checklist.get("script2video/storyboard.json") and checklist.get("script2video/shots/*/shot_description.json") and checklist.get("script2video/camera_tree.json"))
828
828 lines PYTHON