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