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