| 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 |