commit ecb5ae4e594cfb280af6daa0359830e261555fdb Author: wehub-resource-sync Date: Mon Jul 13 12:23:54 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2ad103 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# working directory +.working_dir/ +.vimax/ +.test/ +# Node dependencies +node_modules/ + +# Local agent secrets +configs/*.local.json +configs/*.local.yaml +!configs/agent.local.yaml diff --git a/Communication.md b/Communication.md new file mode 100644 index 0000000..00b5c7e --- /dev/null +++ b/Communication.md @@ -0,0 +1,6 @@ +We provide QR codes for joining the HKUDS discussion groups on WeChat and Feishu. + +You can join by scanning the QR codes below: + +WeChat QR Code + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b77bf2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..3d98e58 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`HKUDS/ViMax` +- 原始仓库:https://github.com/HKUDS/ViMax +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_ZH.md b/README_ZH.md new file mode 100644 index 0000000..689b960 --- /dev/null +++ b/README_ZH.md @@ -0,0 +1,451 @@ +
+ +
+
+

ViMax: Agentic Video Generation

+ +
+
+ +

+ + + MIT License +

+ +

+ + +

+ +

+ + +

+ +
+
+ +

+ + +

+ + + + +
+ +--- + +
+ + + +https://github.com/user-attachments/assets/5bad46b2-8276-4e1d-9480-3522640744b2 + + + + +
+ +--- + + +## 📑 目录 + +- [💡 核心特性](#核心特性) +- [🔮 演示示例](#演示示例) +- [🏗️ 系统架构](#️-系统架构) +- [🚀 快速开始](#快速开始) + +--- +## 💡核心特性 + +
+ + + + + + + + +
+ +
+

🌟 创意到视频

+
+ +
+ 算法徽章 +
+ +
+

从灵感到银幕

+
+ +
+

通过智能多智能体工作流,将原始创意自动转化为完整视频故事,涵盖叙事构建、角色设计与视频制作全流程。 +

+
+ + + +
+ +
+

🎨 小说到视频

+
+ +
+ 前端徽章 +
+ +
+

智能文学改编引擎

+
+ +
+

完整小说智能压缩并转化为分集视频内容,实现角色追踪、叙事压缩与逐场景视觉化改编。

+
+ + + +
+ +
+

⚙️ 剧本到视频

+
+ +
+ 后端徽章 +
+ +
+

无限剧本视频创作

+
+ +
+

自由创作任意剧本——从个人故事到史诗冒险,全面掌控视觉叙事的每个细节。

+
+ + + + + +
+ +
+

🤳 智能客串

+
+ +
+ 后端徽章 +
+ +
+

用你的照片生成视频

+
+ +
+

创建属于你的客串视频,将自己融入无限创意剧本、电影级镜头与互动剧情中,成为故事中的明星角色。

+
+ + + +
+ +
+ + +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + +--- + +### 🎯 **端到端视频创作引擎** + +**面临的挑战**: + +- 🌅 **参考图像**:获取、整理并精准对齐能准确表达角色、物体、位置与环境的参考帧,耗时费力。 + +- 🫠 **一致性校验**:即使提供了正确的角色、位置、环境参考图与提示词,图像生成器有时仍会产出不可用图像。 + +- 📄 **剧本生成**:专业高质量视频需要高信息密度与结构化设计。 + +- 📝 **分镜设计**:将故事转化为视觉叙事,需要摄影、构图与视觉叙事的专业知识,而大多数创作者并不具备。 + +- 🎬 **镜头设计**:在复杂场景中保持叙事连贯性的同时,设计合理的镜头角度、转场与节奏。 + +- 🎨 **风格一致性**:在长视频中确保数百个镜头的角色外观、环境与艺术风格保持一致。 + +- ⏱️ **制作效率**:传统视频制作依赖多个专业人员与冗长流程,阻碍了独立创作者与快速原型开发。 + +- 🎥 **AI视频扩展性**:AI生成视频通常仅几秒,而分钟级甚至小时级的高质量长视频需要复杂的跨场景连续性与多分镜协同处理能力。 + +**ViMAX**:通过自动化从叙事输入到最终视频输出的完整流程,彻底消除上述制作瓶颈。 + +--- + + +### 🔥 **为什么选择 ViMax?** + +| 🧠 **一键生成** | 🚀 **完全创作自由** | 🔊 **音画同步** | 🎨 **专业品质** | 🤩 **互动视频** +|:---:|:---:|:---:|:---:|:---:| +| 一句话生成完整视频 | 任何叙事皆可成真 | 音画完美融合 | 电影级输出 | 生成你的专属客串视频 +| 无需技术细节——只需描述你的创意,ViMax 自动完成剧本生成、分镜设计、镜头规划、参考管理与一致性验证 | 创意无边界——无论是预告片、短篇故事、小说章节还是原创概念,ViMax 都能智能构建叙事并设计镜头语言,将任何想法变为现实 | 无缝融合角色语音与音效,打造沉浸式视听体验 | 自动质量控制确保角色一致性、场景构图合理、每帧画面均达专业水准 | 上传你的照片即可在自己的故事中互动出演——ViMax 智能将你作为角色融入视频,保持外观一致并实现自然交互 + + + +--- + + +## 🏗️ 系统架构 + +### 📊 **系统概览** + +**ViMax** 是一个多智能体视频生成框架,支持自动化多镜头视频生成,并确保角色与场景的一致性。系统能将你的创意无缝转化为对应视频,让你专注于讲故事,而非技术实现。 + +🎯 **技术能力**: + +🧬 **智能长剧本生成** +基于 RAG 的长剧本引擎,可智能分析小说级长文本,并自动切分为多场景剧本格式,精准保留关键情节与角色对话。 + +🪄 **表现力分镜设计** +基于用户需求与目标受众,运用电影语言生成富有表现力的镜头级分镜,为后续视频生成奠定叙事节奏。 + +🔮 **多机位拍摄模拟** +模拟多机位拍摄,提供沉浸式观看体验,同时确保同一场景内角色位置与背景的一致性。 + +🧸 **智能参考图选择** +智能选取当前视频首帧所需的参考图(包括前序时间线中的分镜),确保视频越长,多角色与环境元素越准确。 + +⚙️ **自动化图像生成** +基于所选参考图与前序时间线的视觉逻辑,自动生成图像生成器提示词,合理安排角色与环境的空间交互位置。 + +✅ **图像生成一致性校验** +并行生成多张图像,并通过 MLLM/VLM 选择最一致的图像作为首帧,模拟人类创作者的工作流程。 + +⚡ **高效并行镜头生成** +对同一机位拍摄的连续镜头进行并行处理,极大提升视频生产效率。 + + + + + +### 🤖 多智能体视频生成流水线 + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🧠 输入层
+ 📝 创意/剧本/小说 • 💭 自然语言提示 • 🖼️ 参考图像 • 🎨 风格指令 • 🧩 配置参数 +
+ 🧭 中央调度
+ 智能体调度 • 阶段切换 • 资源管理 • 重试/降级逻辑 +
+ 🧾 剧本理解
+ 角色/环境提取 • 场景边界识别 • 风格意图解析 +
+ 🎥 场景与镜头规划
+ 分镜步骤 • 镜头列表 • 关键帧与节奏点 +
+ 🧪 视觉资产规划
+ 参考图选择 • 外观/风格引导 • 提示词条件化 +
+ 🗂️ 资产索引
+ 帧/参考图目录 • 嵌入向量 • 复用检索 +
+ ♻️ 一致性与连续性
+ 角色/环境追踪 • 参考匹配 • 时序连贯性 +
+ ✂️ 视觉合成与组装
+ 图像生成 • 最佳帧选择 • 首尾帧→视频 • 剪辑与时间线合成 +
+ 🚀 输出层
+ 🖼️ 帧图像 • 🎞️ 片段与最终视频 • 📜 日志 • 📦 工作目录产物 +
+
+ + + +## 🚀Quick Start + +### 🖥️ **Environment** + +``` +OS: Linux, Windows +``` + +### 📥 **Clone and Install** +We use uv to manage the environment. For uv installation, please refer to the https://docs.astral.sh/uv/getting-started/installation/. +```bash +git clone https://github.com/HKUDS/ViMax.git +cd ViMax +uv sync +``` + + +### 🧠 **Agent TUI / Agents Loop** +ViMax also provides a minimal TUI for interactive agent-based video creation. Configure `configs/agent.local.yaml` for the LLM, image, and video providers, then start the TUI from the ViMax root directory. +```bash +vimax tui +``` + +Start a new session or resume an existing one: +```bash +vimax tui new +vimax tui resume +vimax tui resume +``` + +### 🎯 **Usage** +main_idea2video.py is used to convert your ideas into videos. +You need to configure the model and API key information in the configs/idea2video.yaml file, including three parts—the chat model, the image generator, and the video generator, as shown below +```yaml +chat_model: + init_args: + model: google/gemini-2.5-flash-lite-preview-09-2025 + model_provider: openai + api_key: + base_url: https://openrouter.ai/api/v1 + +image_generator: + class_path: tools.ImageGeneratorNanobananaGoogleAPI + init_args: + api_key: + +video_generator: + class_path: tools.VideoGeneratorVeoGoogleAPI + init_args: + api_key: + +working_dir: .working_dir/idea2video +``` + +Then, provide a simple yet thoughtful idea and the corresponding creative requirements in main_idea2video.py. +```bash +idea = \ +""" +If a cat and a dog are best friends, what would happen when they meet a new cat? +""" +user_requirement = \ +""" +For children, do not exceed 3 scenes. +""" +style = "Cartoon" +``` + +main_script2video.py generates a video based on a specific script. +You similarly need to set up the API configuration in configs/script2video.yaml file. Then, provide a scene script and the corresponding creative requirements in main_script2video.py, as shown below. +```python +script = \ +""" +EXT. SCHOOL GYM - DAY +A group of students are practicing basketball in the gym. The gym is large and open, with a basketball hoop at one end and a large crowd of spectators at the other end. John (18, male, tall, athletic) is the star player, and he is practicing his dribble and shot. Jane (17, female, short, athletic) is the assistant coach, and she is helping John with his practice. The other students are watching the practice and cheering for John. +John: (dribbling the ball) I'm going to score a basket! +Jane: (smiling) Good job, John! +John: (shooting the ball) Yes! +... +""" +user_requirement = \ +""" +Fast-paced with no more than 20 shots. +""" +style = "Animate Style" +``` + + +--- + diff --git a/agent_runtime/__init__.py b/agent_runtime/__init__.py new file mode 100644 index 0000000..bbd274a --- /dev/null +++ b/agent_runtime/__init__.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +__all__ = ["AgentLoop", "SessionIndex", "ToolRegistry", "build_runtime"] + + +def build_runtime(*args, **kwargs): + from .loop import build_runtime as _build_runtime + + return _build_runtime(*args, **kwargs) + + +def __getattr__(name): + if name == "AgentLoop": + from .loop import AgentLoop + + return AgentLoop + if name == "SessionIndex": + from .session_index import SessionIndex + + return SessionIndex + if name == "ToolRegistry": + from .tools import ToolRegistry + + return ToolRegistry + raise AttributeError(name) diff --git a/agent_runtime/config.py b/agent_runtime/config.py new file mode 100644 index 0000000..b9ac9f6 --- /dev/null +++ b/agent_runtime/config.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + +DEFAULT_LLM_MODEL = "gpt-5.5" +DEFAULT_LLM_MODEL_PROVIDER = "openai" +DEFAULT_LLM_BASE_URL = "https://yunwu.ai/v1" +DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image-preview" +DEFAULT_IMAGE_BASE_URL = "https://yunwu.ai" +DEFAULT_VIDEO_MODEL = "veo3.1-fast" +DEFAULT_VIDEO_BASE_URL = "https://openrouter.ai/api/v1" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" +DEFAULT_EMBEDDING_MODEL_PROVIDER = "openai" +DEFAULT_RERANKER_MODEL = "BAAI/bge-reranker-v2-m3" + + +@lru_cache(maxsize=4) +def load_agent_config(workspace_root: str | Path = ".") -> dict[str, Any]: + path = Path(workspace_root).resolve() / "configs" / "agent.local.yaml" + if not path.exists(): + return {} + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + raise RuntimeError(f"Invalid configs/agent.local.yaml: {exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError("configs/agent.local.yaml must be a YAML mapping") + return payload + + +def config_value(section: str, key: str, env_names: list[str], default: str = "", workspace_root: str | Path = ".") -> str: + for env_name in env_names: + value = os.environ.get(env_name) + if value: + return value + section_payload = load_agent_config(workspace_root).get(section, {}) + if isinstance(section_payload, dict): + value = section_payload.get(key) + if isinstance(value, str) and value: + return value + return default + + +def llm_model(workspace_root: str | Path = ".") -> str: + return config_value("llm", "model", ["VIMAX_LLM_MODEL"], DEFAULT_LLM_MODEL, workspace_root) + + +def llm_model_provider(workspace_root: str | Path = ".") -> str: + return config_value("llm", "model_provider", ["VIMAX_LLM_MODEL_PROVIDER"], DEFAULT_LLM_MODEL_PROVIDER, workspace_root) + + +def llm_base_url(workspace_root: str | Path = ".") -> str: + return config_value("llm", "base_url", ["VIMAX_LLM_BASE_URL"], DEFAULT_LLM_BASE_URL, workspace_root) + + +def llm_api_key(workspace_root: str | Path = ".") -> str: + return config_value("llm", "api_key", ["VIMAX_LLM_API_KEY", "VIMAX_API_KEY"], "", workspace_root) + + +def image_model(workspace_root: str | Path = ".") -> str: + return config_value("image", "model", ["VIMAX_IMAGE_MODEL"], DEFAULT_IMAGE_MODEL, workspace_root) + + +def image_base_url(workspace_root: str | Path = ".") -> str: + return config_value("image", "base_url", ["VIMAX_IMAGE_BASE_URL"], DEFAULT_IMAGE_BASE_URL, workspace_root) + + +def image_api_key(workspace_root: str | Path = ".") -> str: + return config_value("image", "api_key", ["VIMAX_IMAGE_API_KEY", "VIMAX_LLM_API_KEY", "VIMAX_API_KEY"], llm_api_key(workspace_root), workspace_root) + + + +def embedding_model(workspace_root: str | Path = ".") -> str: + return config_value("embedding", "model", ["VIMAX_EMBEDDING_MODEL"], DEFAULT_EMBEDDING_MODEL, workspace_root) + + +def embedding_model_provider(workspace_root: str | Path = ".") -> str: + return config_value("embedding", "model_provider", ["VIMAX_EMBEDDING_MODEL_PROVIDER"], DEFAULT_EMBEDDING_MODEL_PROVIDER, workspace_root) + + +def embedding_base_url(workspace_root: str | Path = ".") -> str: + return config_value("embedding", "base_url", ["VIMAX_EMBEDDING_BASE_URL"], "", workspace_root) + + +def embedding_api_key(workspace_root: str | Path = ".") -> str: + return config_value("embedding", "api_key", ["VIMAX_EMBEDDING_API_KEY"], "", workspace_root) + + +def reranker_model(workspace_root: str | Path = ".") -> str: + return config_value("reranker", "model", ["VIMAX_RERANKER_MODEL"], DEFAULT_RERANKER_MODEL, workspace_root) + + +def reranker_base_url(workspace_root: str | Path = ".") -> str: + return config_value("reranker", "base_url", ["VIMAX_RERANKER_BASE_URL"], "", workspace_root) + + +def reranker_api_key(workspace_root: str | Path = ".") -> str: + return config_value("reranker", "api_key", ["VIMAX_RERANKER_API_KEY"], "", workspace_root) + + +def video_model(workspace_root: str | Path = ".") -> str: + return config_value("video", "model", ["VIMAX_VIDEO_MODEL"], DEFAULT_VIDEO_MODEL, workspace_root) + + +def video_base_url(workspace_root: str | Path = ".") -> str: + return config_value("video", "base_url", ["VIMAX_VIDEO_BASE_URL"], DEFAULT_VIDEO_BASE_URL, workspace_root) + + +def video_api_key(workspace_root: str | Path = ".") -> str: + return config_value("video", "api_key", ["VIMAX_VIDEO_API_KEY", "VIMAX_LLM_API_KEY", "VIMAX_API_KEY"], llm_api_key(workspace_root), workspace_root) + + +def api_provider_from_base_url(base_url: str) -> str: + normalized = base_url.strip().lower() + if "openrouter.ai" in normalized: + return "openrouter" + if "yunwu.ai" in normalized: + return "yunwu" + return "" + + +def video_provider(workspace_root: str | Path = ".") -> str: + """Infer the video API relay/provider from video.base_url. + + This is not a model provider setting. OpenRouter/Yunwu are transport/API + gateways here, so users should configure base_url and let the adapter pick + the matching implementation. + """ + return api_provider_from_base_url(video_base_url(workspace_root)) diff --git a/agent_runtime/context_compactor.py b/agent_runtime/context_compactor.py new file mode 100644 index 0000000..411ee9c --- /dev/null +++ b/agent_runtime/context_compactor.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +SUMMARY_SECTIONS = [ + "Reference Context Only", + "Active Task", + "Completed Actions", + "Important Files", + "Decisions", + "Errors & Risks", + "Remaining Work", + "Critical Context", +] + + +@dataclass(slots=True) +class CompactionResult: + summary: str + preserved_messages: list[dict[str, Any]] + compacted_message_count: int + estimated_tokens_before: int + estimated_tokens_after: int + reason: str + mode: str + created_at: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds")) + + +class ContextCompactor: + def __init__( + self, + llm: Any | None = None, + *, + token_threshold: int | None = None, + buffer_tokens: int | None = None, + preserve_last_n: int | None = None, + max_messages: int | None = None, + summary_max_chars: int | None = None, + ) -> None: + self.llm = llm + configured_threshold = token_threshold if token_threshold is not None else _default_token_threshold() + self.token_threshold = _env_int("VIMAX_AUTO_COMPACT_TOKEN_THRESHOLD", configured_threshold) + self.buffer_tokens = _env_int("VIMAX_AUTO_COMPACT_BUFFER_TOKENS", buffer_tokens if buffer_tokens is not None else 20000) + self.preserve_last_n = _env_int("VIMAX_COMPACT_PRESERVE_LAST_N", preserve_last_n if preserve_last_n is not None else 6) + self.max_messages = _env_int("VIMAX_COMPACT_MAX_MESSAGES", max_messages if max_messages is not None else 48) + self.summary_max_chars = _env_int("VIMAX_COMPACT_SUMMARY_MAX_CHARS", summary_max_chars if summary_max_chars is not None else 6000) + + def compact_target_tokens(self) -> int: + if self.token_threshold <= 0: + return 0 + return max(0, self.token_threshold - max(0, self.buffer_tokens)) + + def estimate_message_tokens(self, message: dict[str, Any]) -> int: + role = str(message.get("role", "user") or "user") + content = str(message.get("content", "") or "") + metadata = {key: value for key, value in message.items() if key not in {"role", "content"}} + word_count = len(re.findall(r"\w+", content)) + line_count = content.count("\n") + 1 if content else 0 + punctuation_count = len(re.findall(r"[^\w\s]", content)) + role_overhead = {"system": 18, "user": 12, "assistant": 14, "tool": 16}.get(role, 12) + metadata_bonus = min(300, len(json.dumps(metadata, ensure_ascii=False, default=str)) // 6) if metadata else 0 + tool_bonus = 80 if "tool_calls" in message or role == "tool" else 0 + return max(role_overhead, role_overhead + len(content) // 4 + word_count // 2 + line_count * 2 + punctuation_count // 4 + metadata_bonus + tool_bonus) + + def estimate_messages_tokens(self, messages: list[dict[str, Any]]) -> int: + return sum(self.estimate_message_tokens(message) for message in messages) + + def should_preflight_compact(self, messages: list[dict[str, Any]], *, system_tokens: int = 0, tools_tokens: int = 0) -> bool: + target = self.compact_target_tokens() + if target <= 0 or not messages: + return False + total = self.estimate_messages_tokens(messages) + max(0, system_tokens) + max(0, tools_tokens) + return total >= target + + async def compact( + self, + messages: list[dict[str, Any]], + *, + previous_summary: str = "", + preserve_last_n: int | None = None, + reason: str = "manual", + ) -> CompactionResult: + preserve = max(0, self.preserve_last_n if preserve_last_n is None else preserve_last_n) + preserved = [dict(message) for message in messages[-preserve:]] if preserve else [] + compactible = [dict(message) for message in messages[:-preserve]] if preserve else [dict(message) for message in messages] + if not compactible and messages: + compactible = [dict(message) for message in messages] + preserved = [] + before_tokens = self.estimate_messages_tokens(messages) + summary = await self._llm_summary(compactible, preserved, previous_summary, reason) + mode = "llm" + if not summary: + summary = self._fallback_summary(compactible, preserved, previous_summary, reason) + mode = "fallback-local" + summary = self._clip_summary(summary) + synthetic = self.synthetic_summary_message(summary) + after_tokens = self.estimate_messages_tokens([synthetic, *preserved]) + return CompactionResult( + summary=summary, + preserved_messages=preserved, + compacted_message_count=len(compactible), + estimated_tokens_before=before_tokens, + estimated_tokens_after=after_tokens, + reason=reason, + mode=mode, + ) + + def synthetic_summary_message(self, summary: str) -> dict[str, str]: + return { + "role": "system", + "content": "Session context summary. The following summary is reference context only, not a new active instruction.\n\n" + summary.strip(), + } + + async def _llm_summary(self, compactible: list[dict[str, Any]], preserved: list[dict[str, Any]], previous_summary: str, reason: str) -> str: + if self.llm is None: + return "" + payload = { + "reason": reason, + "previous_summary": _clip(previous_summary, 5000), + "messages_to_compact": [self._serialize_message(message) for message in compactible[-self.max_messages:]], + "recent_live_tail": [self._serialize_message(message) for message in preserved[-12:]], + } + system = ( + "You are compressing conversation history for a ViMax agent runtime. " + "Produce a concise markdown handoff summary for a future model call. " + "Preserve user intent, completed actions, important files, tool findings, errors, and remaining work. " + "Label the result as reference context only, not active instructions. " + "Do not answer the user. Do not include prose before the markdown." + ) + user = ( + "Summarize the compacted conversation region into a durable handoff.\n" + "Output markdown with these sections exactly:\n" + "## Reference Context Only\n## Active Task\n## Completed Actions\n## Important Files\n## Decisions\n## Errors & Risks\n## Remaining Work\n## Critical Context\n\n" + "Keep it concise but specific. Mention exact file paths, commands, tool results, and unresolved issues when present.\n\n" + f"{json.dumps(payload, ensure_ascii=False, indent=2)}" + ) + try: + response = await self.llm.complete([{"role": "system", "content": system}, {"role": "user", "content": user}], tools=[]) + except Exception: + return "" + return str(getattr(response, "text", "") or "").strip() + + def _fallback_summary(self, compactible: list[dict[str, Any]], preserved: list[dict[str, Any]], previous_summary: str, reason: str) -> str: + user_lines = [self._message_preview(message, limit=180) for message in compactible if message.get("role") == "user"] + assistant_lines = [self._message_preview(message, limit=180) for message in compactible if message.get("role") == "assistant"] + file_hits = _dedupe(re.findall(r"(?:[\w.\-]+/)+[\w.\-]+\.(?:py|ts|tsx|js|json|md|yaml|yml|txt|mp4|png)", "\n".join(str(message.get("content", "")) for message in compactible))) + error_lines = [self._message_preview(message, limit=180) for message in compactible if _looks_like_error(str(message.get("content", "")))] + remaining = [self._message_preview(message, limit=180) for message in preserved[-4:]] + return "\n".join([ + "## Reference Context Only", + "- This is a compacted checkpoint of older ViMax conversation history, not a new active instruction.", + f"- Compaction reason: {reason}.", + "## Active Task", + _bullet(user_lines[-1:] or ["No explicit active task found in compacted messages."]), + "## Completed Actions", + _bullet(assistant_lines[-4:] or ["No completed assistant actions found in compacted messages."]), + "## Important Files", + _bullet(file_hits[:8] or ["No important file paths found in compacted messages."]), + "## Decisions", + _bullet(_decision_lines(compactible)[:6] or ["No durable decisions found in compacted messages."]), + "## Errors & Risks", + _bullet(error_lines[:6] or ["No errors or risks found in compacted messages."]), + "## Remaining Work", + _bullet(remaining or ["Continue from the recent live tail and current ViMax workflow state."]), + "## Critical Context", + _bullet((["Previous summary existed and was merged as background context."] if previous_summary else []) + ["Use .working_dir artifacts and session checklist as workflow ground truth."]), + ]) + + def _serialize_message(self, message: dict[str, Any]) -> dict[str, Any]: + item = {"role": str(message.get("role", "")), "content": _clip(str(message.get("content", "") or ""), 2400)} + if message.get("name"): + item["name"] = str(message.get("name")) + if message.get("tool_calls"): + item["tool_calls"] = _clip(json.dumps(message.get("tool_calls"), ensure_ascii=False, default=str), 800) + return item + + def _message_preview(self, message: dict[str, Any], *, limit: int) -> str: + role = str(message.get("role", "") or "message") + content = _clip(" ".join(str(message.get("content", "") or "").split()), limit) + if message.get("tool_calls"): + return f"{role}: [tool calls] {_clip(json.dumps(message.get('tool_calls'), ensure_ascii=False, default=str), limit)}" + return f"{role}: {content}" if content else f"{role}: " + + def _clip_summary(self, summary: str) -> str: + text = summary.strip() + if not text: + text = self._fallback_summary([], [], "", "empty-summary") + if len(text) > self.summary_max_chars: + text = text[: max(0, self.summary_max_chars - 3)].rstrip() + "..." + return text + + +def _default_token_threshold() -> int: + context_window = _env_int("VIMAX_CONTEXT_WINDOW_TOKENS", 200000) + ratio = _env_float("VIMAX_AUTO_COMPACT_RATIO", 0.90) + ratio = min(1.0, max(0.0, ratio)) + return int(context_window * ratio) + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.environ.get(name, str(default))) + except ValueError: + return default + + +def _clip(text: str, limit: int) -> str: + compact = " ".join(str(text or "").split()) + if len(compact) <= limit: + return compact + return compact[: max(0, limit - 3)].rstrip() + "..." + + +def _bullet(items: list[str]) -> str: + return "\n".join(f"- {item}" for item in items if str(item).strip()) + + +def _dedupe(items: list[str]) -> list[str]: + seen: list[str] = [] + for item in items: + normalized = " ".join(str(item).split()) + if normalized and normalized not in seen: + seen.append(normalized) + return seen + + +def _looks_like_error(text: str) -> bool: + lowered = text.lower() + return any(token in lowered for token in ("error", "failed", "failure", "timeout", "not found", "blocked", "permission")) + + +def _decision_lines(messages: list[dict[str, Any]]) -> list[str]: + tokens = ("decision", "decided", "prefer", "keep ", "switch ", "use ", "preserve ", "avoid ") + rows: list[str] = [] + for message in messages: + content = str(message.get("content", "") or "") + for raw in content.splitlines(): + line = raw.strip(" -") + if line and any(token in line.lower() for token in tokens): + rows.append(_clip(line, 180)) + return _dedupe(rows) diff --git a/agent_runtime/llm.py b/agent_runtime/llm.py new file mode 100644 index 0000000..ed3b08f --- /dev/null +++ b/agent_runtime/llm.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from typing import Any +from uuid import uuid4 + +from openai import APIConnectionError, APITimeoutError, AsyncOpenAI + +from .config import llm_api_key, llm_base_url, llm_model +from .models import ToolCall + + +LLM_MAX_ATTEMPTS = 3 +LLM_RETRY_BACKOFF_SECONDS = (1.0, 4.0) +LLM_REQUEST_TIMEOUT_SECONDS = 300.0 + + +def _is_retryable_llm_error(exc: BaseException) -> bool: + status = getattr(exc, "status_code", None) + if status is not None: + try: + status = int(status) + except (TypeError, ValueError): + return False + return status == 429 or status >= 500 + return isinstance(exc, (APIConnectionError, APITimeoutError)) + + +class LLMResponseShapeError(RuntimeError): + pass + + +@dataclass(slots=True) +class AssistantMessage: + text: str = "" + tool_calls: list[ToolCall] = field(default_factory=list) + raw_message: dict[str, Any] = field(default_factory=dict) + + +class OpenAICompatibleLLM: + def __init__(self, model: str | None = None, base_url: str | None = None, api_key: str | None = None) -> None: + self.model = model or llm_model() + self.base_url = base_url or llm_base_url() + self.api_key = api_key or llm_api_key() + if not self.api_key: + raise RuntimeError("VIMAX_LLM_API_KEY is required for the agent LLM client") + self.client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, timeout=LLM_REQUEST_TIMEOUT_SECONDS) + + async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantMessage: + shape_attempts = [ + {"tools": tools or None, "tool_choice": "auto" if tools else None}, + {"tools": tools or None, "tool_choice": "auto" if tools else None}, + ] + if tools: + shape_attempts.append({"tools": None, "tool_choice": None}) + + last_shape_error: Exception | None = None + for attempt in shape_attempts: + try: + response = await self._create_completion_with_retries(messages, attempt["tools"], attempt["tool_choice"]) + return _assistant_message_from_response(response) + except LLMResponseShapeError as exc: + last_shape_error = exc + continue + assert last_shape_error is not None + raise last_shape_error + + async def _create_completion_with_retries(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, tool_choice: str | None) -> Any: + for attempt in range(LLM_MAX_ATTEMPTS): + try: + return await self._create_completion(messages, tools, tool_choice) + except Exception as exc: + if isinstance(exc, LLMResponseShapeError) or attempt == LLM_MAX_ATTEMPTS - 1 or not _is_retryable_llm_error(exc): + raise + delay = LLM_RETRY_BACKOFF_SECONDS[min(attempt, len(LLM_RETRY_BACKOFF_SECONDS) - 1)] + logging.warning("LLM call failed (%s); retrying in %.1fs (attempt %d/%d)", exc, delay, attempt + 1, LLM_MAX_ATTEMPTS) + await asyncio.sleep(delay) + + async def _create_completion(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, tool_choice: str | None) -> Any: + kwargs: dict[str, Any] = { + "model": self.model, + "messages": messages, + "stream": False, + } + if tools: + kwargs["tools"] = tools + if tool_choice: + kwargs["tool_choice"] = tool_choice + return await self.client.chat.completions.create(**kwargs) + + +def _assistant_message_from_response(response: Any) -> AssistantMessage: + message = _extract_message(response) + text = _message_value(message, "content") or "" + calls: list[ToolCall] = [] + for call in _message_value(message, "tool_calls") or []: + function = _message_value(call, "function") or {} + try: + arguments = json.loads(_message_value(function, "arguments") or "{}") + except json.JSONDecodeError: + arguments = {} + calls.append(ToolCall(id=_message_value(call, "id") or f"tool-{uuid4().hex[:12]}", name=_message_value(function, "name"), arguments=arguments)) + return AssistantMessage(text=text, tool_calls=calls, raw_message=_dump_message(message)) + + +def _extract_message(response: Any) -> Any: + if isinstance(response, str): + try: + response = json.loads(response) + except json.JSONDecodeError as exc: + raise LLMResponseShapeError(f"LLM provider returned a string instead of a chat completion object: {response[:300]}") from exc + choices = _message_value(response, "choices") + if not choices: + raise LLMResponseShapeError(f"LLM provider response missing choices: {str(response)[:500]}") + first_choice = choices[0] + message = _message_value(first_choice, "message") + if message is None: + raise LLMResponseShapeError(f"LLM provider response missing choice.message: {str(response)[:500]}") + return message + + +def _message_value(obj: Any, key: str) -> Any: + if isinstance(obj, dict): + return obj.get(key) + return getattr(obj, key, None) + + +def _dump_message(message: Any) -> dict[str, Any]: + if isinstance(message, dict): + return message + if hasattr(message, "model_dump"): + return message.model_dump() + return {"content": str(message)} diff --git a/agent_runtime/loop.py b/agent_runtime/loop.py new file mode 100644 index 0000000..c32b84c --- /dev/null +++ b/agent_runtime/loop.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import asyncio +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncIterator + +from .context_compactor import ContextCompactor, CompactionResult +from .llm import OpenAICompatibleLLM +from .models import ToolCall, ToolResult, TurnControl +from .prompts import PromptBuilder +from .session_index import SessionIndex +from .tool_executor import ToolExecutor +from .tools import ToolRegistry, build_builtin_registry + +MAX_TOOL_PASSES = 50 + + +class AgentLoop: + def __init__(self, session_index: SessionIndex, prompt_builder: PromptBuilder, tool_registry: ToolRegistry, tool_executor: ToolExecutor, llm: Any, context_compactor: ContextCompactor | None = None) -> None: + self.session_index = session_index + self.prompt_builder = prompt_builder + self.tool_registry = tool_registry + self.tool_executor = tool_executor + self.llm = llm + self.context_compactor = context_compactor or ContextCompactor(llm) + self.history: list[dict[str, Any]] = [] + + async def compact_history(self, *, reason: str = "manual") -> str: + if not self.history: + return "No conversation history to compact." + session = self.session_index.active() or self.session_index.create() + result = await self.context_compactor.compact( + self.history, + previous_summary=str(session.get("compacted_summary", "") or ""), + reason=reason, + ) + self.history = [self.context_compactor.synthetic_summary_message(result.summary), *result.preserved_messages] + self.session_index.update_compaction(session["session_id"], _compaction_record(result)) + return f"Compacted context {result.estimated_tokens_before} -> {result.estimated_tokens_after} ({result.mode})." + + async def stream_events(self, user_input: str) -> AsyncIterator[dict[str, Any]]: + control = TurnControl() + yield {"type": "turn", "turn_id": control.turn_id, "turn": {"id": control.turn_id}} + tool_schemas = self.tool_registry.list_function_tools() + parts = self.prompt_builder.build_parts(user_input) + system = "\n\n".join(f"## {part.title}\n{part.body}" for part in parts if part.id != "request.user") + if self.context_compactor.should_preflight_compact( + [*self.history, {"role": "user", "content": user_input}], + system_tokens=_prompt_tokens(parts), + tools_tokens=_tool_schema_tokens(tool_schemas), + ): + yield {"type": "status", "turn_id": control.turn_id, "phase": "compact", "message": "Compacting context before sampling"} + await self.compact_history(reason="token-pressure") + parts = self.prompt_builder.build_parts(user_input) + system = "\n\n".join(f"## {part.title}\n{part.body}" for part in parts if part.id != "request.user") + yield {"type": "prompt_trace", "turn_id": control.turn_id, "prompt_trace": self.prompt_builder.trace(parts)} + runtime_messages: list[dict[str, Any]] = [{"role": "system", "content": system}, *self.history, {"role": "user", "content": user_input}] + assistant_turns: list[dict[str, Any]] = [] + tool_rounds: list[dict[str, Any]] = [] + transitions: list[dict[str, str]] = [] + all_tool_results: list[ToolResult] = [] + final_text = "" + status = "completed" + tool_round = 0 + + while True: + yield {"type": "status", "turn_id": control.turn_id, "phase": "sampling_assistant", "message": "Sampling assistant"} + try: + assistant = await self.llm.complete(runtime_messages, tools=tool_schemas) + except Exception as exc: + status = "failed" + final_text = f"Agent LLM request failed: {exc}" + transitions.append(_transition("sampling_assistant", "finalizing_answer", "llm_sampling_failed")) + yield {"type": "error", "turn_id": control.turn_id, "message": final_text, "metadata": {"error_type": "llm_sampling_failed"}} + break + assistant_turns.append({"phase": "initial" if tool_round == 0 else f"followup_{tool_round}", "text": assistant.text, "tool_calls": [call.as_dict() for call in assistant.tool_calls]}) + if not assistant.tool_calls: + transitions.append(_transition("sampling_assistant", "finalizing_answer", "assistant_finished_without_tools")) + final_text = assistant.text + if final_text: + yield {"type": "token", "turn_id": control.turn_id, "delta": final_text} + break + transitions.append(_transition("sampling_assistant", "executing_tools", "assistant_requested_tools")) + if tool_round >= MAX_TOOL_PASSES: + status = "halted" + final_text = "Tool loop halted after max tool passes." + transitions.append(_transition("executing_tools", "finalizing_answer", "max_tool_passes_reached")) + yield {"type": "error", "turn_id": control.turn_id, "message": final_text, "metadata": {"max_tool_passes": MAX_TOOL_PASSES}} + break + tool_round += 1 + yield {"type": "status", "turn_id": control.turn_id, "phase": "executing_tools", "message": f"Running tools (round {tool_round})"} + runtime_messages.append({"role": "assistant", "content": assistant.text or "", "tool_calls": [_openai_tool_call(call) for call in assistant.tool_calls]}) + round_results: list[ToolResult] = [] + + for call in assistant.tool_calls: + yield {"type": "tool_start", "turn_id": control.turn_id, "tool": call.as_dict()} + progress_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + def on_progress(event: dict[str, Any]) -> None: + progress_queue.put_nowait(event) + + task = asyncio.create_task(self.tool_executor.execute(call, control, progress_callback=on_progress)) + while not task.done(): + try: + yield await asyncio.wait_for(progress_queue.get(), timeout=0.1) + except asyncio.TimeoutError: + continue + while not progress_queue.empty(): + yield progress_queue.get_nowait() + record = await task + result = record.result + round_results.append(result) + all_tool_results.append(result) + yield {"type": "tool_result", "turn_id": control.turn_id, "tool_result": result.as_dict()} + runtime_messages.append({"role": "tool", "tool_call_id": call.id, "name": result.name, "content": json.dumps(result.as_dict(), ensure_ascii=False)}) + tool_rounds.append({"tool_round": tool_round, "requested_tools": [call.as_dict() for call in assistant.tool_calls], "tool_results": [result.as_dict() for result in round_results]}) + transitions.append(_transition("executing_tools", "post_tool_decision", "tool_round_completed")) + transitions.append(_transition("post_tool_decision", "sampling_assistant", "runtime_continuation_after_tools")) + + self.history.extend([{"role": "user", "content": user_input}, {"role": "assistant", "content": final_text}]) + turn_record = {"turn_id": control.turn_id, "status": status, "raw_user_input": user_input, "assistant_turns": assistant_turns, "tool_rounds": tool_rounds, "transitions": transitions, "final_assistant_text": final_text, "created_at": datetime.now().isoformat(timespec="seconds")} + final_session = self.session_index.active() or self.session_index.create() + self.session_index.append_turn_record(final_session["session_id"], turn_record) + yield {"type": "done", "turn_id": control.turn_id, "assistant": final_text, "tool_results": [result.as_dict() for result in all_tool_results]} + yield {"type": "session", "turn_id": control.turn_id, "session": self.session_index.snapshot()} + + +def _compaction_record(result: CompactionResult) -> dict[str, Any]: + return { + "summary": result.summary, + "preserved_message_count": len(result.preserved_messages), + "compacted_message_count": result.compacted_message_count, + "estimated_tokens_before": result.estimated_tokens_before, + "estimated_tokens_after": result.estimated_tokens_after, + "reason": result.reason, + "mode": result.mode, + "created_at": result.created_at, + } + + +def _prompt_tokens(parts: list[Any]) -> int: + return sum(max(1, len(str(getattr(part, "body", ""))) // 4) for part in parts) + + +def _tool_schema_tokens(tool_schemas: list[dict[str, Any]]) -> int: + try: + return max(0, len(json.dumps(tool_schemas, ensure_ascii=False, default=str)) // 4) + except TypeError: + return max(0, len(str(tool_schemas)) // 4) + + +def _transition(src: str, dst: str, reason: str) -> dict[str, str]: + return {"from": src, "to": dst, "reason": reason} + + +def _openai_tool_call(call: ToolCall) -> dict[str, Any]: + return {"id": call.id, "type": "function", "function": {"name": call.name, "arguments": json.dumps(call.arguments, ensure_ascii=False)}} + + +def build_runtime(workspace_root: str | Path = ".", llm: Any | None = None, adapter_specs: list[Any] | None = None) -> AgentLoop: + from .vimax_adapters import build_vimax_adapter_specs + root = Path(workspace_root).resolve() + session_index = SessionIndex(root) + specs = adapter_specs if adapter_specs is not None else build_vimax_adapter_specs(root, session_index) + registry = build_builtin_registry(root, session_index, specs) + executor = ToolExecutor(registry, session_index) + prompt_builder = PromptBuilder(root / "prompts", session_index, registry) + resolved_llm = llm or OpenAICompatibleLLM() + return AgentLoop(session_index, prompt_builder, registry, executor, resolved_llm, ContextCompactor(resolved_llm)) diff --git a/agent_runtime/models.py b/agent_runtime/models.py new file mode 100644 index 0000000..a056086 --- /dev/null +++ b/agent_runtime/models.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from threading import Event +from time import time +from typing import Any, Literal +from uuid import uuid4 + + +@dataclass(slots=True) +class ToolCall: + name: str + arguments: dict[str, Any] = field(default_factory=dict) + id: str = field(default_factory=lambda: f"tool-{uuid4().hex[:12]}") + + def as_dict(self) -> dict[str, Any]: + return {"id": self.id, "name": self.name, "arguments": self.arguments} + + +@dataclass(slots=True) +class ToolResult: + name: str + ok: bool + content: str + metadata: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return {"name": self.name, "ok": self.ok, "content": self.content, "metadata": dict(self.metadata)} + + +@dataclass(slots=True) +class TurnControl: + turn_id: str = field(default_factory=lambda: f"turn-{uuid4().hex[:12]}") + cancel_event: Event = field(default_factory=Event) + cancel_reason: str = "" + + def cancel(self, reason: str = "") -> None: + self.cancel_reason = reason.strip() + self.cancel_event.set() + + +@dataclass(slots=True) +class SessionRecord: + session_id: str + working_dir: str + idea: str = "" + user_requirement: str = "" + style: str = "" + stage: str = "created" + summary: str = "" + stale: dict[str, bool] = field(default_factory=dict) + created_at: str = "" + updated_at: str = "" + + +StreamEventType = Literal["turn", "status", "token", "tool_start", "tool_progress", "tool_result", "terminal", "done", "session", "error", "prompt_trace"] + + +def now_ts() -> float: + return time() diff --git a/agent_runtime/prompts.py b/agent_runtime/prompts.py new file mode 100644 index 0000000..d475733 --- /dev/null +++ b/agent_runtime/prompts.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(slots=True) +class PromptPart: + id: str + title: str + body: str + zone: str + category: str + cacheable: bool = False + + +class PromptBuilder: + def __init__(self, prompt_dir: str | Path, session_index: Any, tool_registry: Any) -> None: + self.prompt_dir = Path(prompt_dir) + self.session_index = session_index + self.tool_registry = tool_registry + + def build_parts(self, user_input: str) -> list[PromptPart]: + return [ + PromptPart("agent.core", "Agent", self._read_prompt("agent.md"), "stable", "agent", True), + PromptPart("workflow.core", "Workflow", self._read_prompt("workflow.md"), "stable", "workflow", True), + PromptPart("tool.manifest", "Tools", self.tool_manifest_context(), "dynamic", "tooling"), + PromptPart("session.context", "Session", self.workflow_context(), "dynamic", "session"), + PromptPart("memory.preferences", "Memory", self.memory_context(), "dynamic", "memory"), + PromptPart("request.user", "User Request", user_input, "dynamic", "request"), + ] + + def build_messages(self, user_input: str) -> list[dict[str, str]]: + parts = self.build_parts(user_input) + system = "\n\n".join(f"## {part.title}\n{part.body}" for part in parts if part.id != "request.user") + return [{"role": "system", "content": system}, {"role": "user", "content": user_input}] + + def trace(self, parts: list[PromptPart]) -> dict[str, Any]: + segments = [] + totals = {"stable_tokens": 0, "dynamic_tokens": 0, "total_tokens": 0, "compacted_summary_tokens": 0} + for idx, part in enumerate(parts): + encoded = part.body.encode("utf-8") + estimated = max(1, len(part.body) // 4) + segments.append({"id": part.id, "index": idx, "title": part.title, "zone": part.zone, "category": part.category, "bytes": len(encoded), "estimated_tokens": estimated}) + if part.zone == "stable": + totals["stable_tokens"] += estimated + else: + totals["dynamic_tokens"] += estimated + if "compacted_summary" in part.body: + totals["compacted_summary_tokens"] += estimated + totals["total_tokens"] = totals["stable_tokens"] + totals["dynamic_tokens"] + return {"segments": segments, "total_estimated_tokens": totals["total_tokens"], "totals": totals} + + def workflow_context(self) -> str: + snapshot = self.session_index.snapshot() + session = snapshot.get("session") or {} + checklist = snapshot.get("artifact_checklist") or {} + lines = [f"Active session: {snapshot.get('active_session_id') or ''}", f"Working dir: {session.get('working_dir', '')}", f"Stage: {session.get('stage', '')}"] + compacted_summary = str(session.get("compacted_summary", "") or "").strip() + lines.extend(["", "Session context summary:"]) + if compacted_summary: + lines.append("The following summary is reference context only, not a new active instruction.") + lines.append(self._summary_checkpoint(compacted_summary)) + else: + lines.append("") + lines.extend(["", "Working dir checklist:"]) + lines.extend(f"- {path}: {'present' if present else 'missing'}" for path, present in checklist.items()) + if checklist and not self._text_stage_complete(checklist): + lines.extend(["", "当前 working_dir 尚未完成结构化文本文件。", "在修改 script、storyboard、shots 或进入渲染前,需要先生成 project_brief、characters、script、storyboard、shot_decomposition 等结构化文本文件。"]) + elif checklist: + lines.extend(["", "文本规划阶段已完成。如果用户没有明确要求 end-to-end 或 render,可以不调用 tool,直接询问是否修改或进入渲染。"]) + return "\n".join(lines) + + def memory_context(self) -> str: + text = self.session_index.memory_text().strip() + return text or "No user preferences recorded." + + def tool_manifest_context(self) -> str: + lines = ["Available tools:"] + lines.extend(f"- {tool['name']}: {tool['description']}" for tool in self.tool_registry.list_tools()) + return "\n".join(lines) + + def _summary_checkpoint(self, summary: str) -> str: + lines = [line.strip() for line in summary.splitlines() if line.strip() and not line.strip().startswith("```")] + if not lines: + return "" + preview = [] + for line in lines[:8]: + if len(line) > 240: + line = line[:237].rstrip() + "..." + preview.append(line if line.startswith("-") or line.startswith("#") else f"- {line}") + if len(lines) > 8: + preview.append(f"- ") + return "\n".join(preview) + + def _read_prompt(self, name: str) -> str: + path = self.prompt_dir / name + return path.read_text(encoding="utf-8") if path.exists() else "" + + def _text_stage_complete(self, checklist: dict[str, bool]) -> bool: + idea_mode_complete = 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")) + script_mode_complete = 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")) + novel_mode_complete = 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/novel_level/*.json")) + return idea_mode_complete or script_mode_complete or novel_mode_complete diff --git a/agent_runtime/session_index.py b/agent_runtime/session_index.py new file mode 100644 index 0000000..c9360a0 --- /dev/null +++ b/agent_runtime/session_index.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import json +import logging +import os +import re +from contextlib import contextmanager +from datetime import datetime +from functools import wraps +from pathlib import Path +from typing import Any + +try: + import fcntl +except ImportError: # pragma: no cover - non-POSIX platforms + fcntl = None + + +STALE_KEYS = ["story", "characters", "script", "storyboard", "shot_descriptions", "camera_tree", "frames", "clips", "final_video"] + + +def _synchronized(method): + """Hold the index file lock across a read-modify-write cycle. + + Every mutator loads the whole sessions file, edits it, and saves it back; + without a lock, two concurrent writers (threads or processes) silently + drop each other's updates. + """ + + @wraps(method) + def wrapper(self, *args, **kwargs): + with self._locked(): + return method(self, *args, **kwargs) + + return wrapper + + +class SessionIndex: + def __init__(self, workspace_root: str | Path) -> None: + self.workspace_root = Path(workspace_root).resolve() + self.vimax_dir = self.workspace_root / ".vimax" + self.sessions_path = self.vimax_dir / "sessions.json" + self.memory_path = self.vimax_dir / "memory.md" + self.logs_dir = self.vimax_dir / "logs" + self.working_root = self.workspace_root / ".working_dir" + self.vimax_dir.mkdir(parents=True, exist_ok=True) + self.logs_dir.mkdir(parents=True, exist_ok=True) + self.working_root.mkdir(parents=True, exist_ok=True) + if not self.memory_path.exists(): + self.memory_path.write_text("# User Preferences\n", encoding="utf-8") + if not self.sessions_path.exists(): + self.save({"active_session_id": "", "sessions": {}}) + + @contextmanager + def _locked(self): + if fcntl is None: + yield + return + lock_path = self.vimax_dir / "sessions.lock" + with open(lock_path, "a+", encoding="utf-8") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + def load(self) -> dict[str, Any]: + try: + return json.loads(self.sessions_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"active_session_id": "", "sessions": {}} + except json.JSONDecodeError: + # A corrupt file usually means a crash mid-write. Returning empty + # state is fine for this call, but the next save() would overwrite + # the file and destroy every session — keep the evidence first. + backup = self.sessions_path.with_name(f"sessions.json.corrupt-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}") + try: + os.replace(self.sessions_path, backup) + logging.error("sessions.json was corrupt; preserved it at %s and starting with empty state", backup) + except OSError: + logging.error("sessions.json is corrupt and could not be backed up; starting with empty state") + return {"active_session_id": "", "sessions": {}} + + def save(self, data: dict[str, Any]) -> None: + tmp_path = self.sessions_path.with_name("sessions.json.tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp_path, self.sessions_path) + + def active(self) -> dict[str, Any] | None: + data = self.load() + session_id = str(data.get("active_session_id", "")) + if not session_id: + return None + record = data.get("sessions", {}).get(session_id) + return self._with_session_defaults(record) if isinstance(record, dict) else None + + def get(self, session_id: str) -> dict[str, Any] | None: + normalized = self._normalize_session_id(session_id) + record = self.load().get("sessions", {}).get(normalized) + return self._with_session_defaults(record) if isinstance(record, dict) else None + + @_synchronized + def create(self, idea: str = "", user_requirement: str = "", style: str = "", session_id: str | None = None) -> dict[str, Any]: + data = self.load() + sessions = data.setdefault("sessions", {}) + final_id = self._normalize_session_id(session_id) if session_id else self._new_session_id(idea or user_requirement or "vimax", sessions) + if final_id in sessions: + final_id = self._dedupe_session_id(final_id, sessions) + now = datetime.now().isoformat(timespec="seconds") + working_dir = self._working_dir_for_id(final_id) + (working_dir / "idea2video").mkdir(parents=True, exist_ok=True) + (working_dir / "script2video").mkdir(parents=True, exist_ok=True) + record = { + "session_id": final_id, + "working_dir": str(working_dir.relative_to(self.workspace_root)), + "idea": idea, + "user_requirement": user_requirement, + "style": style, + "stage": "created", + "summary": "", + "stale": {key: False for key in STALE_KEYS}, + "recent_turn_records": [], + "compacted_summary": "", + "compacted_turns": 0, + "compaction_snapshots": [], + "last_compaction_reason": "", + "last_compaction_at": "", + "created_at": now, + "updated_at": now, + } + sessions[final_id] = record + data["active_session_id"] = final_id + self.save(data) + return record + + def get_or_create_active(self, idea: str = "", user_requirement: str = "", style: str = "") -> dict[str, Any]: + active = self.active() + if active is not None: + return active + return self.create(idea=idea, user_requirement=user_requirement, style=style) + + @_synchronized + def set_active(self, session_id: str) -> dict[str, Any]: + normalized = self._normalize_session_id(session_id) + data = self.load() + if normalized not in data.get("sessions", {}): + raise KeyError(f"Unknown session_id: {session_id}") + data["active_session_id"] = normalized + self.save(data) + return dict(data["sessions"][normalized]) + + @_synchronized + def update_stage(self, session_id: str, stage: str, summary: str = "") -> None: + data = self.load() + record = data.get("sessions", {}).get(session_id) + if not isinstance(record, dict): + raise KeyError(f"Unknown session_id: {session_id}") + record["stage"] = stage + if summary: + record["summary"] = summary + record["updated_at"] = datetime.now().isoformat(timespec="seconds") + self.save(data) + + @_synchronized + def mark_stale(self, session_id: str, keys: list[str]) -> None: + data = self.load() + record = data.get("sessions", {}).get(session_id) + if not isinstance(record, dict): + raise KeyError(f"Unknown session_id: {session_id}") + stale = record.setdefault("stale", {key: False for key in STALE_KEYS}) + for key in keys: + stale[key] = True + record["updated_at"] = datetime.now().isoformat(timespec="seconds") + self.save(data) + + @_synchronized + def update_compaction(self, session_id: str, result: dict[str, Any]) -> None: + data = self.load() + session = data.get("sessions", {}).get(session_id) + if not isinstance(session, dict): + raise KeyError(f"Unknown session_id: {session_id}") + summary = str(result.get("summary", "") or "") + compacted_count = int(result.get("compacted_message_count", 0) or 0) + snapshot = { + "level": len(session.get("compaction_snapshots", []) or []) + 1, + "reason": str(result.get("reason", "manual") or "manual"), + "mode": str(result.get("mode", "unknown") or "unknown"), + "summary": summary, + "preserved_messages": int(result.get("preserved_message_count", 0) or 0), + "compacted_message_count": compacted_count, + "estimated_tokens_before": int(result.get("estimated_tokens_before", 0) or 0), + "estimated_tokens_after": int(result.get("estimated_tokens_after", 0) or 0), + "created_at": str(result.get("created_at", "") or datetime.now().isoformat(timespec="seconds")), + } + session["compacted_summary"] = summary + session["compacted_turns"] = int(session.get("compacted_turns", 0) or 0) + max(1, compacted_count // 2) + snapshots = list(session.get("compaction_snapshots", []) or []) + snapshots.append(snapshot) + session["compaction_snapshots"] = snapshots[-8:] + session["last_compaction_reason"] = snapshot["reason"] + session["last_compaction_at"] = snapshot["created_at"] + session["updated_at"] = datetime.now().isoformat(timespec="seconds") + self.save(data) + self.append_log("loop_history", {"session_id": session_id, "event": "context_compacted", "compaction": snapshot}) + + def compacted_summary(self, session_id: str | None = None) -> str: + record = self.get(session_id) if session_id else self.active() + return str((record or {}).get("compacted_summary", "") or "") + + @_synchronized + def append_turn_record(self, session_id: str, record: dict[str, Any]) -> None: + data = self.load() + session = data.get("sessions", {}).get(session_id) + if isinstance(session, dict): + recent = session.setdefault("recent_turn_records", []) + recent.append({ + "turn_id": record.get("turn_id", ""), + "status": record.get("status", ""), + "tool_round_count": len(record.get("tool_rounds", [])), + "final_preview": str(record.get("final_assistant_text", ""))[:240], + "created_at": record.get("created_at", ""), + }) + session["recent_turn_records"] = recent[-6:] + session["updated_at"] = datetime.now().isoformat(timespec="seconds") + self.save(data) + self.append_log("loop_history", {"session_id": session_id, **record}) + + def working_dir(self, session_id: str | None = None) -> Path: + record = self.get(session_id) if session_id else self.active() + if record is None: + record = self.create() + path = (self.workspace_root / str(record["working_dir"])).resolve() + if path != self.working_root and self.working_root not in path.parents: + raise ValueError(f"Session working_dir escapes .working_dir: {record.get('working_dir')}") + path.mkdir(parents=True, exist_ok=True) + return path + + def artifact_checklist(self, session_id: str | None = None) -> dict[str, bool]: + root = self.working_dir(session_id) + idea_dir = root / "idea2video" + idea_scene_dirs = sorted(path for path in idea_dir.glob("scene_*") if path.is_dir()) if idea_dir.exists() else [] + idea_scene_storyboards = [path / "storyboard.json" for path in idea_scene_dirs] + idea_scene_camera_trees = [path / "camera_tree.json" for path in idea_scene_dirs] + idea_scene_shot_desc_groups = [list((scene / "shots").glob("*/shot_description.json")) for scene in idea_scene_dirs] + idea_scene_selector_outputs = [output for scene in idea_scene_dirs for output in (scene / "shots").glob("*/*_selector_output.json")] + + script_shots = root / "script2video" / "shots" + script_shot_descs = list(script_shots.glob("*/shot_description.json")) if script_shots.exists() else [] + script_selector_outputs = list(script_shots.glob("*/*_selector_output.json")) if script_shots.exists() else [] + + novel_dir = root / "novel2video" + novel_events = list((novel_dir / "events").glob("event_*.json")) if novel_dir.exists() else [] + novel_relevant_chunks = [path for path in (novel_dir / "relevant_chunks").glob("event_*/*") if path.is_file()] if novel_dir.exists() else [] + novel_scenes = list((novel_dir / "scenes").glob("event_*/scene_*.json")) if novel_dir.exists() else [] + novel_event_chars = list((novel_dir / "global_information" / "characters" / "event_level").glob("event_*_characters.json")) if novel_dir.exists() else [] + novel_level_chars = list((novel_dir / "global_information" / "characters" / "novel_level").glob("novel_characters_after_event_*.json")) if novel_dir.exists() else [] + return { + "idea2video/story.txt": (idea_dir / "story.txt").exists(), + "idea2video/characters.json": (idea_dir / "characters.json").exists(), + "idea2video/script.json": (idea_dir / "script.json").exists(), + "idea2video/scene_*/storyboard.json": bool(idea_scene_storyboards) and all(path.exists() for path in idea_scene_storyboards), + "idea2video/scene_*/camera_tree.json": bool(idea_scene_camera_trees) and all(path.exists() for path in idea_scene_camera_trees), + "idea2video/scene_*/shots/*/shot_description.json": bool(idea_scene_shot_desc_groups) and all(idea_scene_shot_desc_groups), + "idea2video/scene_*/shots/*/*_selector_output.json": bool(idea_scene_selector_outputs), + "idea2video/final_video.mp4": (idea_dir / "final_video.mp4").exists(), + "script2video/script.txt": (root / "script2video" / "script.txt").exists(), + "script2video/characters.json": (root / "script2video" / "characters.json").exists(), + "script2video/storyboard.json": (root / "script2video" / "storyboard.json").exists(), + "script2video/shots/*/shot_description.json": bool(script_shot_descs), + "script2video/camera_tree.json": (root / "script2video" / "camera_tree.json").exists(), + "script2video/shots/*/*_selector_output.json": bool(script_selector_outputs), + "script2video/final_video.mp4": (root / "script2video" / "final_video.mp4").exists(), + "novel2video/novel/novel.txt": (novel_dir / "novel" / "novel.txt").exists(), + "novel2video/novel/novel_compressed.txt": (novel_dir / "novel" / "novel_compressed.txt").exists(), + "novel2video/events/event_*.json": bool(novel_events), + "novel2video/relevant_chunks/event_*": bool(novel_relevant_chunks), + "novel2video/scenes/event_*/scene_*.json": bool(novel_scenes), + "novel2video/global_information/characters/event_level/*.json": bool(novel_event_chars), + "novel2video/global_information/characters/novel_level/*.json": bool(novel_level_chars), + } + + def memory_text(self) -> str: + return self.memory_path.read_text(encoding="utf-8") if self.memory_path.exists() else "" + + def write_memory(self, text: str) -> None: + self.memory_path.write_text(text, encoding="utf-8") + + def append_log(self, name: str, payload: dict[str, Any]) -> None: + event = {"timestamp": datetime.now().isoformat(timespec="seconds"), **payload} + path = self.logs_dir / f"{name}.jsonl" + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n") + + def snapshot(self) -> dict[str, Any]: + active = self.active() + if active is None: + return {"active_session_id": "", "session": None} + return {"active_session_id": active["session_id"], "session": active, "artifact_checklist": self.artifact_checklist(active["session_id"])} + + def _with_session_defaults(self, record: dict[str, Any]) -> dict[str, Any]: + item = dict(record) + item.setdefault("compacted_summary", "") + item.setdefault("compacted_turns", 0) + item.setdefault("compaction_snapshots", []) + item.setdefault("last_compaction_reason", "") + item.setdefault("last_compaction_at", "") + item.setdefault("recent_turn_records", []) + return item + + def _new_session_id(self, source: str, sessions: dict[str, Any]) -> str: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + slug = (re.sub(r"[^a-zA-Z0-9]+", "-", source.lower()).strip("-")[:32].strip("-") or "vimax") + return self._dedupe_session_id(f"{stamp}-{slug}", sessions) + + def _dedupe_session_id(self, base: str, sessions: dict[str, Any]) -> str: + candidate = base + counter = 2 + while candidate in sessions: + candidate = f"{base}-{counter}" + counter += 1 + return candidate + + def _normalize_session_id(self, session_id: str | None) -> str: + raw = str(session_id or "").strip() + if not raw: + raise ValueError("session_id cannot be empty") + normalized = re.sub(r"[^a-zA-Z0-9]+", "-", raw).strip("-")[:96] + if not normalized: + raise ValueError(f"Invalid session_id: {session_id}") + return normalized + + def _working_dir_for_id(self, session_id: str) -> Path: + path = (self.working_root / session_id).resolve() + if path != self.working_root and self.working_root not in path.parents: + raise ValueError(f"Session path escapes .working_dir: {session_id}") + return path diff --git a/agent_runtime/tool_executor.py b/agent_runtime/tool_executor.py new file mode 100644 index 0000000..7f5f91f --- /dev/null +++ b/agent_runtime/tool_executor.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from time import time +from typing import Any, Callable + +from .models import ToolCall, ToolResult, TurnControl +from .tools import ToolRegistry, ToolRuntimeContext + + +@dataclass(slots=True) +class ToolExecutionRecord: + requested_name: str + canonical_name: str + arguments_before: dict[str, Any] + arguments_after: dict[str, Any] + result: ToolResult + started_at: float + finished_at: float + telemetry: dict[str, Any] + + +class ToolExecutor: + def __init__(self, registry: ToolRegistry, session_index: Any) -> None: + self.registry = registry + self.session_index = session_index + + async def execute(self, call: ToolCall, control: TurnControl, progress_callback: Callable[[dict[str, Any]], None] | None = None) -> ToolExecutionRecord: + requested_name = call.name + canonical_name = self.registry.resolve_name(call.name) + before = deepcopy(call.arguments) + started_at = time() + validated, validation_error = self.registry.validate_arguments(canonical_name, call.arguments) + arguments = validated if validated is not None else call.arguments + runtime = ToolRuntimeContext(requested_name=requested_name, canonical_name=canonical_name, turn_id=control.turn_id, cancel_event=control.cancel_event, progress_callback=progress_callback, metadata={"cancel_reason": control.cancel_reason}) + if validation_error: + result = ToolResult(canonical_name, False, validation_error, {"validation_error": True}) + elif control.cancel_event.is_set(): + result = ToolResult(canonical_name, False, control.cancel_reason or "Tool execution cancelled", {"cancelled": True}) + else: + result = await self.registry.execute(canonical_name, arguments, runtime=runtime) + finished_at = time() + telemetry = {"duration_ms": int((finished_at - started_at) * 1000), "requested_name": requested_name, "canonical_name": canonical_name, "result_ok": result.ok} + self.session_index.append_log("tool_calls", {"turn_id": control.turn_id, "tool": canonical_name, "arguments_preview": str(before)[:500], "ok": result.ok, "content_preview": result.content[:500], **telemetry}) + return ToolExecutionRecord(requested_name, canonical_name, before, deepcopy(arguments), result, started_at, finished_at, telemetry) diff --git a/agent_runtime/tools.py b/agent_runtime/tools.py new file mode 100644 index 0000000..076cd0a --- /dev/null +++ b/agent_runtime/tools.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import asyncio +import glob +import inspect +import json +import os +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from threading import Event +from typing import Any, Awaitable, Callable + +from .models import ToolCall, ToolResult + +ToolHandler = Callable[..., Awaitable[ToolResult] | ToolResult] +ProgressCallback = Callable[[dict[str, Any]], None] + + +@dataclass(slots=True) +class ToolArgumentSchema: + type: type | tuple[type, ...] + required: bool = False + default: Any = None + + +@dataclass(slots=True) +class ToolSpec: + name: str + description: str + handler: ToolHandler + aliases: tuple[str, ...] = () + permission_mode: str = "workspace-write" + schema: dict[str, ToolArgumentSchema] | None = None + json_schema: dict[str, Any] | None = None + concurrency_safe: bool = False + + +@dataclass(slots=True) +class ToolRuntimeContext: + requested_name: str + canonical_name: str + turn_id: str = "" + cancel_event: Event | None = None + progress_callback: ProgressCallback | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def emit_progress(self, message: str, *, stage: str = "running", metadata: dict[str, Any] | None = None) -> None: + if self.progress_callback is None: + return + payload: dict[str, Any] = { + "type": "tool_progress", + "tool": {"requested_name": self.requested_name, "name": self.canonical_name}, + "progress": {"stage": stage, "message": message, "metadata": metadata or {}}, + } + if self.turn_id: + payload["turn_id"] = self.turn_id + self.progress_callback(payload) + + def emit_terminal(self, line: str, *, stream: str = "stdout") -> None: + if self.progress_callback is None: + return + if not line: + return + payload: dict[str, Any] = {"type": "terminal", "stream": stream, "line": line} + if self.turn_id: + payload["turn_id"] = self.turn_id + self.progress_callback(payload) + + def is_cancelled(self) -> bool: + return self.cancel_event.is_set() if self.cancel_event is not None else False + + def raise_if_cancelled(self, default_reason: str = "Tool execution cancelled") -> None: + if self.is_cancelled(): + raise RuntimeError(str(self.metadata.get("cancel_reason") or default_reason)) + + +class ToolRegistry: + def __init__(self, specs: list[ToolSpec] | None = None) -> None: + self._specs: dict[str, ToolSpec] = {} + self._aliases: dict[str, str] = {} + for spec in specs or []: + self.register(spec) + + def register(self, spec: ToolSpec) -> None: + self._specs[spec.name] = spec + for alias in spec.aliases: + self._aliases[alias] = spec.name + + def list_tools(self) -> list[dict[str, str]]: + return sorted([{"name": spec.name, "description": spec.description, "permission_mode": spec.permission_mode} for spec in self._specs.values()], key=lambda item: item["name"]) + + def list_function_tools(self) -> list[dict[str, Any]]: + tools = [] + for spec in sorted(self._specs.values(), key=lambda item: item.name): + parameters = spec.json_schema or _argument_schema_to_json_schema(spec.schema or {}) + tools.append({"type": "function", "function": {"name": spec.name, "description": spec.description, "parameters": parameters}}) + return tools + + def get_spec(self, name: str) -> ToolSpec | None: + return self._specs.get(self.resolve_name(name)) + + def resolve_name(self, name: str) -> str: + normalized = name.strip() + return self._aliases.get(normalized, normalized) + + def validate_arguments(self, name: str, arguments: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]: + spec = self.get_spec(name) + if spec is None: + return None, f"Unknown tool: {name}" + schema = spec.schema or {} + normalized = dict(arguments or {}) + for field_name, field_spec in schema.items(): + if field_name not in normalized: + if field_spec.required and field_spec.default is None: + return None, f"Missing required argument '{field_name}' for {spec.name}" + if field_spec.default is not None: + normalized[field_name] = field_spec.default + continue + value = normalized[field_name] + expected = field_spec.type + if expected is bool and isinstance(value, str) and value.lower() in {"true", "false"}: + normalized[field_name] = value.lower() == "true" + continue + if expected is int and isinstance(value, str): + try: + normalized[field_name] = int(value) + continue + except ValueError: + return None, f"Argument '{field_name}' for {spec.name} must be an integer" + if not isinstance(normalized[field_name], expected): + expected_name = ", ".join(t.__name__ for t in expected) if isinstance(expected, tuple) else expected.__name__ + return None, f"Argument '{field_name}' for {spec.name} must be {expected_name}" + return normalized, None + + def is_concurrency_safe(self, name: str) -> bool: + spec = self.get_spec(name) + return bool(spec and spec.concurrency_safe) + + def partition_calls(self, calls: list[ToolCall]) -> list[list[ToolCall]]: + batches: list[list[ToolCall]] = [] + for call in calls: + if self.is_concurrency_safe(call.name) and batches and all(self.is_concurrency_safe(item.name) for item in batches[-1]): + batches[-1].append(call) + else: + batches.append([call]) + return batches + + async def execute(self, name: str, arguments: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult: + canonical = self.resolve_name(name) + spec = self._specs.get(canonical) + if spec is None: + return ToolResult(name=name, ok=False, content=f"Unknown tool: {name}", metadata={"error_type": "unknown_tool"}) + handler = spec.handler + try: + params = inspect.signature(handler).parameters + result = handler(arguments, runtime) if runtime is not None and len(params) >= 2 else handler(arguments) + if inspect.isawaitable(result): + return await result + return result + except Exception as exc: + return ToolResult(name=canonical, ok=False, content=str(exc), metadata={"error_type": "exception"}) + + +def _argument_schema_to_json_schema(schema: dict[str, ToolArgumentSchema]) -> dict[str, Any]: + properties: dict[str, Any] = {} + required: list[str] = [] + for field_name, field_spec in schema.items(): + field_schema = _type_to_json_schema(field_spec.type) + if field_spec.default is not None: + field_schema["default"] = field_spec.default + properties[field_name] = field_schema + if field_spec.required and field_spec.default is None: + required.append(field_name) + payload: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False} + if required: + payload["required"] = required + return payload + + +def _type_to_json_schema(tp: type | tuple[type, ...]) -> dict[str, Any]: + if isinstance(tp, tuple): + return {"anyOf": [_type_to_json_schema(item) for item in tp]} + return {str: {"type": "string"}, int: {"type": "integer"}, bool: {"type": "boolean"}, dict: {"type": "object", "additionalProperties": True}, list: {"type": "array", "items": {}}}.get(tp, {"type": "string"}) + + +def build_builtin_registry(workspace_root: str | Path, session_index: Any, adapter_specs: list[ToolSpec] | None = None) -> ToolRegistry: + root = Path(workspace_root).resolve() + + def safe_path(raw: Any) -> Path: + path = (root / str(raw)).resolve() + if root not in path.parents and path != root: + raise ValueError(f"Path escapes workspace: {raw}") + return path + + def _legacy_virtual_read(raw_path: Any, *, as_json: bool) -> ToolResult | None: + """Compatibility for paths older prompts/models may hallucinate. + + The authoritative session state is .vimax/sessions.json and logs are + .vimax/logs/*.jsonl, but some model turns ask for per-session files like + .working_dir//session.json or .vimax/logs/.log. + """ + path = safe_path(raw_path) + try: + rel = path.relative_to(root) + except ValueError: + return None + parts = rel.parts + if len(parts) == 3 and parts[0] == ".working_dir" and parts[2] == "session.json": + session_id = parts[1] + record = session_index.get(session_id) + if record is None: + return None + payload = { + "session": record, + "artifact_checklist": session_index.artifact_checklist(session_id), + "source": ".vimax/sessions.json", + "virtual_path": rel.as_posix(), + } + content = json.dumps(payload, ensure_ascii=False, indent=2) + return ToolResult("read_json" if as_json else "read_file", True, content, {"virtual_path": True, "source": ".vimax/sessions.json"}) + if len(parts) == 3 and parts[0] == ".vimax" and parts[1] == "logs" and parts[2].endswith(".log"): + session_id = parts[2][:-4] + rows: list[dict[str, Any]] = [] + for log_name in ("loop_history", "tool_calls", "revisions"): + log_path = session_index.logs_dir / f"{log_name}.jsonl" + if not log_path.exists(): + continue + for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): + if session_id not in line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + item = {"raw": line} + item["_log"] = log_name + rows.append(item) + payload = { + "session_id": session_id, + "source": ".vimax/logs/*.jsonl", + "virtual_path": rel.as_posix(), + "records": rows, + } + content = json.dumps(payload, ensure_ascii=False, indent=2) + return ToolResult("read_json" if as_json else "read_file", True, content, {"virtual_path": True, "source": ".vimax/logs/*.jsonl", "record_count": len(rows)}) + return None + + def read_file(args: dict[str, Any]) -> ToolResult: + path = safe_path(args["path"]) + if not path.exists(): + virtual = _legacy_virtual_read(args["path"], as_json=False) + if virtual is not None: + return virtual + return ToolResult("read_file", False, f"File not found: {path}") + return ToolResult("read_file", True, path.read_text(encoding="utf-8")) + + def read_json(args: dict[str, Any]) -> ToolResult: + path = safe_path(args["path"]) + if not path.exists(): + virtual = _legacy_virtual_read(args["path"], as_json=True) + if virtual is not None: + return virtual + return ToolResult("read_json", False, f"File not found: {path}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return ToolResult("read_json", False, f"Invalid JSON: {exc}", {"error_type": "invalid_json"}) + return ToolResult("read_json", True, json.dumps(payload, ensure_ascii=False, indent=2)) + + def write_json(args: dict[str, Any]) -> ToolResult: + path = safe_path(args["path"]) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(args["data"], ensure_ascii=False, indent=2), encoding="utf-8") + return ToolResult("write_json", True, f"Wrote JSON {path.relative_to(root)}") + + def list_files(args: dict[str, Any]) -> ToolResult: + path = safe_path(args.get("path", ".")) + if not path.exists(): + return ToolResult("list_files", False, f"Path not found: {path}") + rows = [str(item.relative_to(root)) for item in sorted(path.iterdir())] + return ToolResult("list_files", True, "\n".join(rows) or "No entries") + + def glob_files(args: dict[str, Any]) -> ToolResult: + pattern = str(args["pattern"]) + matches = [str(Path(item).resolve().relative_to(root)) for item in glob.glob(str(root / pattern), recursive=True)] + return ToolResult("glob_files", True, "\n".join(matches[:200]) or "No matches") + + def search_text(args: dict[str, Any]) -> ToolResult: + needle = str(args["query"]) + base = safe_path(args.get("path", ".")) + rows: list[str] = [] + paths = base.rglob("*") if base.is_dir() else [base] + for path in paths: + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for idx, line in enumerate(text.splitlines(), start=1): + if needle in line: + rows.append(f"{path.relative_to(root)}:{idx}: {line}") + if len(rows) >= int(args.get("max_results", 100)): + return ToolResult("search_text", True, "\n".join(rows)) + return ToolResult("search_text", True, "\n".join(rows) or "No matches") + + def memory_read(args: dict[str, Any]) -> ToolResult: + return ToolResult("memory_read", True, session_index.memory_text()) + + def memory_write(args: dict[str, Any]) -> ToolResult: + session_index.write_memory(str(args["content"])) + return ToolResult("memory_write", True, "Updated .vimax/memory.md") + + def todo_path() -> Path: + return root / ".vimax" / "todo.json" + + def todo_read(args: dict[str, Any]) -> ToolResult: + path = todo_path() + if not path.exists(): + return ToolResult("todo_read", True, json.dumps({"items": []}, ensure_ascii=False, indent=2), {"items": []}) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return ToolResult("todo_read", False, f"Invalid todo JSON: {exc}", {"error_type": "invalid_json"}) + items = payload.get("items") + if not isinstance(items, list): + return ToolResult("todo_read", False, "Invalid todo JSON: expected an items array", {"error_type": "invalid_todo"}) + return ToolResult("todo_read", True, json.dumps({"items": items}, ensure_ascii=False, indent=2), {"items": items}) + + def todo_write(args: dict[str, Any]) -> ToolResult: + items = args.get("items") + if not isinstance(items, list): + return ToolResult("todo_write", False, "items must be an array", {"error_type": "invalid_arguments"}) + normalized: list[dict[str, Any]] = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + return ToolResult("todo_write", False, f"items[{index}] must be an object", {"error_type": "invalid_arguments", "index": index}) + content = str(item.get("content", "")).strip() + if not content: + return ToolResult("todo_write", False, f"items[{index}].content is required", {"error_type": "invalid_arguments", "index": index}) + status = str(item.get("status", "pending")).strip() or "pending" + if status not in {"pending", "in_progress", "completed"}: + return ToolResult("todo_write", False, f"items[{index}].status must be pending, in_progress, or completed", {"error_type": "invalid_arguments", "index": index}) + normalized.append({"content": content, "status": status}) + path = todo_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"items": normalized}, ensure_ascii=False, indent=2), encoding="utf-8") + return ToolResult("todo_write", True, f"Updated .vimax/todo.json with {len(normalized)} item(s)", {"items": normalized, "item_count": len(normalized)}) + + async def sleep_tool(args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult: + seconds = float(args.get("seconds", 0)) + if seconds < 0 or seconds > 300: + return ToolResult("sleep", False, "seconds must be between 0 and 300") + if runtime: + runtime.emit_progress(f"Sleeping for {seconds:g}s", stage="running") + await asyncio.sleep(seconds) + return ToolResult("sleep", True, f"Slept for {seconds:g}s") + + async def run_shell(args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult: + if os.environ.get("VIMAX_ENABLE_RUN_SHELL") != "1": + return ToolResult("run_shell", False, "run_shell is disabled by default. Set VIMAX_ENABLE_RUN_SHELL=1 to enable bounded shell commands.", {"error_type": "disabled"}) + command = str(args["command"]).strip() + timeout_seconds = min(max(int(args.get("timeout_seconds", 30)), 1), 120) + output_limit = min(max(int(args.get("output_limit", 20000)), 1000), 50000) + denied_tokens = ["rm ", "rm -", "sudo", "chmod", "chown", "mkfs", "dd ", ":(){", "curl ", "wget ", "ssh ", "printenv", "env", "export"] + lowered = command.lower() + if any(token in lowered for token in denied_tokens): + return ToolResult("run_shell", False, "Command rejected by run_shell policy.", {"error_type": "command_rejected"}) + if runtime: + runtime.emit_progress("Starting shell command", stage="starting", metadata={"command": command, "timeout_seconds": timeout_seconds}) + proc = await asyncio.create_subprocess_shell(command, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds) + except asyncio.TimeoutError: + proc.kill() + await proc.communicate() + return ToolResult("run_shell", False, f"Command timed out after {timeout_seconds}s", {"error_type": "timeout", "timeout_seconds": timeout_seconds}) + content = "" + if stdout: + content += stdout.decode(errors="replace") + if stderr: + content += stderr.decode(errors="replace") + truncated = len(content) > output_limit + if truncated: + content = content[:output_limit] + "\n...[truncated]" + return ToolResult("run_shell", proc.returncode == 0, content, {"returncode": proc.returncode, "truncated": truncated}) + + specs = [ + ToolSpec("read_file", "Read a UTF-8 text file inside the workspace. Also resolves virtual legacy session paths like .vimax/logs/.log.", read_file, schema={"path": ToolArgumentSchema(str, True)}, concurrency_safe=True), + ToolSpec("read_json", "Read and parse a JSON file inside the workspace. Also resolves virtual legacy session paths like .working_dir//session.json.", read_json, schema={"path": ToolArgumentSchema(str, True)}, concurrency_safe=True), + ToolSpec("write_json", "Write formatted JSON inside the workspace.", write_json, schema={"path": ToolArgumentSchema(str, True), "data": ToolArgumentSchema((dict, list), True)}), + ToolSpec("list_files", "List direct children of a workspace path.", list_files, schema={"path": ToolArgumentSchema(str, False, ".")}, concurrency_safe=True), + ToolSpec("glob_files", "Find workspace files with a glob pattern.", glob_files, schema={"pattern": ToolArgumentSchema(str, True)}, concurrency_safe=True), + ToolSpec("search_text", "Search text in workspace files.", search_text, schema={"query": ToolArgumentSchema(str, True), "path": ToolArgumentSchema(str, False, "."), "max_results": ToolArgumentSchema(int, False, 100)}, concurrency_safe=True), + ToolSpec("memory_read", "Read .vimax/memory.md user preferences.", memory_read, schema={}, concurrency_safe=True), + ToolSpec("memory_write", "Replace .vimax/memory.md with user preference notes only.", memory_write, schema={"content": ToolArgumentSchema(str, True)}), + ToolSpec("todo_read", "Read short-term todo items from .vimax/todo.json. This is not a task or team system.", todo_read, schema={}, concurrency_safe=True), + ToolSpec("todo_write", "Replace short-term todo items in .vimax/todo.json. Items require content and may use pending, in_progress, or completed status.", todo_write, schema={"items": ToolArgumentSchema(list, True)}), + ToolSpec("sleep", "Wait for a bounded number of seconds.", sleep_tool, schema={"seconds": ToolArgumentSchema(int, False, 0)}, concurrency_safe=True), + ToolSpec("run_shell", "Run a bounded shell command in the workspace. Disabled unless VIMAX_ENABLE_RUN_SHELL=1; rejects dangerous commands, enforces timeout, and truncates output.", run_shell, schema={"command": ToolArgumentSchema(str, True), "timeout_seconds": ToolArgumentSchema(int, False, 30), "output_limit": ToolArgumentSchema(int, False, 20000)}), + ] + for spec in adapter_specs or []: + specs.append(spec) + return ToolRegistry(specs) diff --git a/agent_runtime/vimax_adapters.py b/agent_runtime/vimax_adapters.py new file mode 100644 index 0000000..70167a5 --- /dev/null +++ b/agent_runtime/vimax_adapters.py @@ -0,0 +1,810 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime +from contextlib import contextmanager, redirect_stderr, redirect_stdout +import json +import logging +import os +from pathlib import Path +from typing import Any + +from langchain.chat_models import init_chat_model +from langchain_openai import OpenAIEmbeddings +from tenacity import RetryError + +from interfaces import CharacterInScene +from agents.event_extractor import EventExtractor +from agents.global_information_planner import GlobalInformationPlanner +from agents.novel_compressor import NovelCompressor +from agents.scene_extractor import SceneExtractor +from pipelines.novel2movie_pipeline import Novel2MoviePipeline +from pipelines.idea2video_pipeline import Idea2VideoPipeline +from pipelines.script2video_pipeline import Script2VideoPipeline +from tools.image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI +from tools.reranker_bge_silicon_api import RerankerBgeSiliconapi +from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI +from tools.video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI + +from .config import 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 +from .models import ToolResult +from .tools import ToolArgumentSchema, ToolRuntimeContext, ToolSpec + + +class _UnavailableGenerator: + async def generate_single_image(self, *args: Any, **kwargs: Any) -> Any: + raise RuntimeError("Image generator is not available in narrative planning mode") + + async def generate_single_video(self, *args: Any, **kwargs: Any) -> Any: + raise RuntimeError("Video generator is not available in narrative planning mode") + + +def build_vimax_adapter_specs(workspace_root: str | Path, session_index: Any) -> list[ToolSpec]: + adapter = ViMaxAdapters(Path(workspace_root), session_index) + return [ + ToolSpec( + name="vimax_narrative_planning", + description=( + "Create or revise ViMax structured text artifacts for the active session. " + "Idea mode writes story, characters, script, and scene-level storyboard/shot_decomposition/camera_tree under idea2video/scene_/. " + "Script mode writes characters, storyboard, shot_decomposition, and camera_tree under script2video/. " + "For a new video idea or new script, omit session_id or pass the new idea/script; the adapter will create a new session instead of reusing mismatched artifacts. If idea/script/revision_target are omitted and the active session has an idea, continue that session and fill missing structured text artifacts. " + "It does not generate keyframes, video clips, or final video. Call this before revising storyboard/shots when those artifacts do not exist." + ), + handler=adapter.vimax_narrative_planning, + schema={ + "session_id": ToolArgumentSchema(str, required=False, default=""), + "idea": ToolArgumentSchema(str, required=False, default=""), + "script": ToolArgumentSchema(str, required=False, default=""), + "user_requirement": ToolArgumentSchema(str, required=False, default=""), + "style": ToolArgumentSchema(str, required=False, default=""), + "revision_target": ToolArgumentSchema(str, required=False, default=""), + "revision_instruction": ToolArgumentSchema(str, required=False, default=""), + }, + ), + ToolSpec( + name="vimax_novel_planning", + description=( + "Create ViMax structured text artifacts from a novel or novel excerpt. " + "This writes novel2video/novel, events, relevant_chunks, scenes, and global_information text artifacts. " + "Use this when the user provides long prose, a novel excerpt, or asks for novel-to-video planning. " + "It does not generate character portraits, scene videos, or final video." + ), + handler=adapter.vimax_novel_planning, + schema={ + "session_id": ToolArgumentSchema(str, required=False, default=""), + "novel_text": ToolArgumentSchema(str, required=True), + "user_requirement": ToolArgumentSchema(str, required=False, default=""), + "style": ToolArgumentSchema(str, required=False, default=""), + }, + ), + ToolSpec( + name="vimax_render_video", + description=( + "Render keyframes, video clips, and final video for the active ViMax session. " + "This checks that structured text artifacts exist before rendering and reports missing dependencies instead of pretending render started." + ), + handler=adapter.vimax_render_video, + schema={ + "session_id": ToolArgumentSchema(str, required=False, default=""), + "mode": ToolArgumentSchema(str, required=False, default="foreground"), + "force": ToolArgumentSchema(bool, required=False, default=False), + }, + ), + ] + + +class ViMaxAdapters: + def __init__(self, workspace_root: Path, session_index: Any) -> None: + self.workspace_root = workspace_root.resolve() + self.session_index = session_index + + async def vimax_narrative_planning(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult: + idea = str(args.get("idea", "") or "").strip() + script = str(args.get("script", "") or "").strip() + user_requirement = str(args.get("user_requirement", "") or "").strip() + requested_style = str(args.get("style", "") or "").strip() + style = requested_style + session = self._resolve_session(str(args.get("session_id", "") or ""), idea=idea, script=script, user_requirement=user_requirement, style=requested_style) + session_id = session["session_id"] + working_dir = self.session_index.working_dir(session_id) + idea_dir = working_dir / "idea2video" + script_dir = working_dir / "script2video" + idea_dir.mkdir(parents=True, exist_ok=True) + script_dir.mkdir(parents=True, exist_ok=True) + + if not idea and not script: + revision_target = str(args.get("revision_target") or "").strip() + if revision_target: + return await self._revise_narrative_artifact(session_id, working_dir, revision_target, str(args.get("revision_instruction") or "").strip(), runtime) + session_idea = str(session.get("idea") or "").strip() + if session_idea: + idea = session_idea + user_requirement = user_requirement or str(session.get("user_requirement") or "").strip() + style = requested_style or str(session.get("style") or "").strip() or "Cinematic, coherent, 16:9" + else: + 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}) + + style = style or str(session.get("style") or "").strip() or "Cinematic, coherent, 16:9" + self._update_session_metadata(session_id, idea="", user_requirement="", style=style) + + try: + self.session_index.update_stage(session_id, "narrative_planning", "Generating structured text artifacts") + if runtime: + runtime.emit_progress("Starting narrative planning", stage="starting", metadata={"session_id": session_id}) + await asyncio.sleep(0) + generated_before = self.session_index.artifact_checklist(session_id) + if runtime: + 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()}) + await asyncio.sleep(0) + chat_model = _build_chat_model() + if runtime: + runtime.emit_progress("Bounded chat model initialized", stage="chat_model_ready", metadata={"session_id": session_id}) + await asyncio.sleep(0) + dummy = _UnavailableGenerator() + # Do not globally redirect stdout/stderr while the JSONL CLI is streaming events. + # The adapter exposes pipeline progress through explicit tool_progress events instead. + if idea: + idea_pipeline = Idea2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(idea_dir)) + if runtime: + runtime.emit_progress("Idea pipeline initialized", stage="idea_pipeline_ready", metadata={"session_id": session_id}) + await asyncio.sleep(0) + story = await _run_planning_step( + "Developing story from user idea", + "develop_story", + idea_pipeline.develop_story(idea=idea, user_requirement=user_requirement, quiet=True), + runtime, + {"session_id": session_id}, + ) + characters = await _run_planning_step( + "Extracting characters from story", + "extract_characters", + idea_pipeline.extract_characters(story=story, quiet=True), + runtime, + {"session_id": session_id}, + ) + scene_scripts = await _run_planning_step( + "Writing scene scripts from story", + "write_script", + idea_pipeline.write_script_based_on_story(story=story, user_requirement=user_requirement, quiet=True), + runtime, + {"session_id": session_id}, + ) + for idx, scene_script in enumerate(scene_scripts if isinstance(scene_scripts, list) else [scene_scripts]): + scene_dir = idea_dir / f"scene_{idx}" + scene_text = scene_script if isinstance(scene_script, str) else json.dumps(scene_script, ensure_ascii=False, indent=2) + script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(scene_dir)) + await _run_planning_step( + f"Planning scene {idx} storyboard and shots", + "plan_scene", + 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), + runtime, + {"session_id": session_id, "scene_index": idx}, + ) + else: + (script_dir / "script.txt").write_text(script, encoding="utf-8") + script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(script_dir)) + if runtime: + runtime.emit_progress("Script pipeline initialized", stage="script_pipeline_ready", metadata={"session_id": session_id}) + await asyncio.sleep(0) + await _run_planning_step( + "Planning storyboard and shots from provided script", + "plan_script", + script_pipeline.plan_text_artifacts(script=script, user_requirement=user_requirement, style=style, progress=_pipeline_progress(runtime, session_id), quiet=True), + runtime, + {"session_id": session_id}, + ) + except Exception as exc: + self.session_index.update_stage(session_id, "error", f"Narrative planning failed: {exc}") + checklist = self.session_index.artifact_checklist(session_id) + payload = { + "session_id": session_id, + "working_dir": str(working_dir.relative_to(self.workspace_root)), + "error_type": "recoverable_planning_step_failed", + "retryable": True, + "error": str(exc), + "present": [path for path, present in checklist.items() if present], + "missing": [path for path, present in checklist.items() if not present], + } + if runtime: + runtime.emit_progress("Narrative planning failed; partial artifacts were kept", stage="planning_failed", metadata=payload) + return ToolResult("vimax_narrative_planning", False, f"Narrative planning failed: {exc}", payload) + + checklist = self.session_index.artifact_checklist(session_id) + generated = [path for path, present in checklist.items() if present and not generated_before.get(path)] + reused = [path for path, present in checklist.items() if present and generated_before.get(path)] + ready_for_render = _ready_for_render(checklist) + self.session_index.update_stage(session_id, "narrative_planned", "Structured text planning complete" if ready_for_render else "Structured text planning partially complete") + if runtime: + runtime.emit_progress("Narrative planning complete", stage="completed", metadata={"ready_for_render": ready_for_render}) + payload = { + "session_id": session_id, + "working_dir": str(working_dir.relative_to(self.workspace_root)), + "generated": generated, + "reused": reused, + "missing": [path for path, present in checklist.items() if not present], + "ready_for_render": ready_for_render, + } + return ToolResult("vimax_narrative_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload) + + async def _revise_narrative_artifact(self, session_id: str, working_dir: Path, revision_target: str, revision_instruction: str, runtime: ToolRuntimeContext | None = None) -> ToolResult: + if not revision_instruction: + self.session_index.update_stage(session_id, "error", "Revision failed: missing revision_instruction") + 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}) + try: + target_path = _resolve_artifact_path(working_dir, revision_target) + except ValueError as exc: + self.session_index.update_stage(session_id, "error", f"Revision failed: {exc}") + return ToolResult("vimax_narrative_planning", False, str(exc), {"error_type": "invalid_revision_target", "session_id": session_id, "revision_target": revision_target}) + if not target_path.exists(): + self.session_index.update_stage(session_id, "error", f"Revision failed: target does not exist: {revision_target}") + 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}) + try: + self.session_index.update_stage(session_id, "narrative_planning", "Revising structured text artifact") + if runtime: + runtime.emit_progress("Revising structured text artifact", stage="revising", metadata={"session_id": session_id, "revision_target": revision_target}) + chat_model = _build_chat_model() + before = target_path.read_text(encoding="utf-8") + revised = await _revise_artifact_with_llm(chat_model, target_path.relative_to(working_dir).as_posix(), before, revision_instruction) + if target_path.suffix == ".json": + try: + revised_payload = json.loads(revised) + except json.JSONDecodeError as exc: + self.session_index.update_stage(session_id, "error", f"Revision failed: invalid JSON output: {exc}") + 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}) + revised = json.dumps(revised_payload, ensure_ascii=False, indent=2) + target_path.write_text(revised, encoding="utf-8") + except Exception as exc: + self.session_index.update_stage(session_id, "error", f"Revision failed: {exc}") + raise + + stale = _stale_keys_for_revision(target_path.relative_to(working_dir).as_posix()) + if stale: + self.session_index.mark_stale(session_id, stale) + 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]}) + checklist = self.session_index.artifact_checklist(session_id) + ready_for_render = _ready_for_render(checklist) + self.session_index.update_stage(session_id, "narrative_planned" if ready_for_render else "narrative_planning", "Revised structured text artifact") + payload = { + "session_id": session_id, + "working_dir": str(working_dir.relative_to(self.workspace_root)), + "generated": [], + "reused": [path for path, present in checklist.items() if present], + "revised": [target_path.relative_to(working_dir).as_posix()], + "missing": [path for path, present in checklist.items() if not present], + "stale": stale, + "ready_for_render": ready_for_render, + "revision_target": target_path.relative_to(working_dir).as_posix(), + } + return ToolResult("vimax_narrative_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload) + + async def vimax_novel_planning(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult: + novel_text = str(args.get("novel_text", "") or "").strip() + user_requirement = str(args.get("user_requirement", "") or "").strip() + style = str(args.get("style", "") or "").strip() or "Cinematic, coherent, 16:9" + if not novel_text: + return ToolResult("vimax_novel_planning", False, "novel_text is required for novel planning.", {"error_type": "missing_input"}) + + session_id_arg = str(args.get("session_id", "") or "").strip() + session = self.session_index.create(idea=novel_text, user_requirement=user_requirement, style=style, session_id=session_id_arg or None) + session_id = session["session_id"] + working_dir = self.session_index.working_dir(session_id) + novel_dir = working_dir / "novel2video" + novel_dir.mkdir(parents=True, exist_ok=True) + generated_before = self.session_index.artifact_checklist(session_id) + + try: + self.session_index.update_stage(session_id, "novel_planning", "Generating novel structured text artifacts") + if runtime: + runtime.emit_progress("Starting novel planning", stage="starting", metadata={"session_id": session_id}) + await asyncio.sleep(0) + pipeline = _build_novel_pipeline(novel_dir) + await _run_planning_step( + "Planning novel structured text artifacts", + "novel_plan_text_artifacts", + pipeline.plan_text_artifacts( + novel_text=novel_text, + user_requirement=user_requirement, + style=style, + progress=_pipeline_progress(runtime, session_id), + quiet=True, + ), + runtime, + {"session_id": session_id}, + ) + except Exception as exc: + self.session_index.update_stage(session_id, "error", f"Novel planning failed: {exc}") + return ToolResult("vimax_novel_planning", False, str(exc), {"error_type": "exception", "session_id": session_id}) + + checklist = self.session_index.artifact_checklist(session_id) + generated = [path for path, present in checklist.items() if path.startswith("novel2video/") and present and not generated_before.get(path)] + reused = [path for path, present in checklist.items() if path.startswith("novel2video/") and present and generated_before.get(path)] + missing = [path for path, present in checklist.items() if path.startswith("novel2video/") and not present] + ready = _novel_text_ready(checklist) + 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") + if runtime: + runtime.emit_progress("Novel planning complete", stage="completed", metadata={"session_id": session_id, "ready_for_scene_render": False}) + payload = { + "session_id": session_id, + "working_dir": str(working_dir.relative_to(self.workspace_root)), + "generated": generated, + "reused": reused, + "missing": missing, + "ready_for_scene_render": False, + } + return ToolResult("vimax_novel_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload) + + async def vimax_render_video(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult: + session_id = str(args.get("session_id", "") or "").strip() + session = self.session_index.get(session_id) if session_id else self.session_index.active() + if session is None: + return ToolResult("vimax_render_video", False, "No active session to render.", {"error_type": "missing_session"}) + session_id = session["session_id"] + checklist = self.session_index.artifact_checklist(session_id) + missing = _missing_render_dependencies(checklist) + working_dir = self.session_index.working_dir(session_id) + if missing: + payload = {"error_type": "dependency_missing", "missing": missing, "session_id": session_id} + _write_render_status(working_dir, status="dependency_missing", payload=payload) + return ToolResult("vimax_render_video", False, f"Dependency missing: {', '.join(missing)}", payload) + + self.session_index.update_stage(session_id, "rendering", "Rendering video artifacts") + _write_render_status(working_dir, status="rendering", payload={"session_id": session_id, "render_started": True, "render_completed": False}) + try: + chat_model = _build_chat_model() + image_generator = _build_image_generator() + video_generator = _build_video_generator() + if runtime: + runtime.emit_progress("Starting video render", stage="rendering", metadata={"session_id": session_id}) + if _idea_mode_ready(checklist): + idea_pipeline = Idea2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(working_dir / "idea2video")) + with _suppress_pipeline_output(): + final_video = await idea_pipeline(idea=str(session.get("idea", "")), user_requirement=str(session.get("user_requirement", "")), style=str(session.get("style", "")), quiet=True) + self.session_index.update_stage(session_id, "rendered", "Final video rendered") + 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": []} + _write_render_status(working_dir, status="rendered", payload=payload) + return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload) + if _script_mode_ready(checklist): + script_dir = working_dir / "script2video" + script_text = _load_script_text(working_dir) + characters = _load_characters(script_dir / "characters.json") + pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(script_dir)) + with _suppress_pipeline_output(): + 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)) + self.session_index.update_stage(session_id, "rendered", "Final video rendered") + 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": []} + _write_render_status(working_dir, status="rendered", payload=payload) + return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload) + if _novel_mode_ready(checklist): + novel_dir = working_dir / "novel2video" + pipeline = _build_novel_render_pipeline(novel_dir, chat_model, image_generator, video_generator) + with _suppress_pipeline_output(): + 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)) + scene_videos_dir = Path(render_result["scene_videos_dir"]) + self.session_index.update_stage(session_id, "novel_scene_rendered", "Novel scene videos rendered") + payload = { + "session_id": session_id, + "render_mode": "novel2video", + "render_started": True, + "render_completed": True, + "scene_render_completed": True, + "final_video_path": None, + "scene_videos_dir": str(scene_videos_dir.relative_to(self.workspace_root)), + "scene_video_dirs": [str(Path(path).relative_to(self.workspace_root)) for path in render_result.get("scene_video_dirs", [])], + "scene_count": render_result.get("scene_count", 0), + "missing": [], + } + _write_render_status(working_dir, status="rendered", payload=payload) + return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload) + except Exception as exc: + unwrapped = _unwrap_retry_error(exc) + error_text = _sanitize_error_text(str(unwrapped)) + wrapped_error_text = _sanitize_error_text(str(exc)) + self.session_index.update_stage(session_id, "error", f"Render failed: {error_text}") + checklist = self.session_index.artifact_checklist(session_id) + payload = { + "error_type": "render_failed", + "retryable": _is_retryable_render_error(unwrapped), + "session_id": session_id, + "error": error_text, + "wrapped_error": wrapped_error_text, + "present": [path for path, present in checklist.items() if present], + "missing": [path for path, present in checklist.items() if not present], + } + _write_render_status(working_dir, status="error", payload=payload) + if runtime: + runtime.emit_progress("Render failed; partial artifacts were kept", stage="render_failed", metadata=payload) + return ToolResult("vimax_render_video", False, f"Render failed: {error_text}", payload) + payload = {"error_type": "dependency_missing", "session_id": session_id} + _write_render_status(working_dir, status="dependency_missing", payload=payload) + return ToolResult("vimax_render_video", False, "No render mode matched current session.", payload) + + def _resolve_session(self, session_id: str, *, idea: str, script: str, user_requirement: str, style: str) -> dict[str, Any]: + requested_source = idea or script + if session_id: + session = self.session_index.get(session_id) + if session is None: + session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style, session_id=session_id) + elif requested_source and _is_new_source_for_session(session, requested_source): + session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style) + else: + self.session_index.set_active(session_id) + else: + if requested_source: + session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style) + else: + session = self.session_index.active() or self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style) + self._update_session_metadata(session["session_id"], idea=requested_source, user_requirement=user_requirement, style=style) + return self.session_index.get(session["session_id"]) or session + + def _update_session_metadata(self, session_id: str, *, idea: str, user_requirement: str, style: str) -> None: + data = self.session_index.load() + record = data.get("sessions", {}).get(session_id) + if not isinstance(record, dict): + return + if idea and not record.get("idea"): + record["idea"] = idea + if user_requirement: + record["user_requirement"] = user_requirement + if style: + record["style"] = style + self.session_index.save(data) + + +class _DiscardStream: + def write(self, text: str) -> int: + return len(text) + + def flush(self) -> None: + pass + + +_PIPELINE_OUTPUT_SINK = _DiscardStream() + + +@contextmanager +def _suppress_pipeline_output(): + previous_disable_level = logging.root.manager.disable + logging.disable(logging.WARNING) + try: + with redirect_stdout(_PIPELINE_OUTPUT_SINK), redirect_stderr(_PIPELINE_OUTPUT_SINK): + yield + finally: + logging.disable(previous_disable_level) + + +def _narrative_step_timeout_seconds() -> float: + raw = os.environ.get("VIMAX_NARRATIVE_STEP_TIMEOUT_SECONDS", "900") + try: + return max(0.0, float(raw)) + except ValueError: + return 900.0 + + +async def _run_planning_step( + message: str, + stage: str, + awaitable: Any, + runtime: ToolRuntimeContext | None, + metadata: dict[str, Any] | None = None, +) -> Any: + timeout_seconds = _narrative_step_timeout_seconds() + event_metadata = dict(metadata or {}) + event_metadata["timeout_seconds"] = timeout_seconds + if runtime: + runtime.emit_progress(message, stage=stage, metadata=event_metadata) + await asyncio.sleep(0) + try: + with _suppress_pipeline_output(): + if timeout_seconds <= 0: + return await awaitable + return await asyncio.wait_for(awaitable, timeout=timeout_seconds) + except asyncio.TimeoutError as exc: + raise RuntimeError(f"{message} timed out after {timeout_seconds:g}s") from exc + except Exception as exc: + raise RuntimeError(f"{message} failed: {exc}") from exc + + +def _is_new_source_for_session(session: dict[str, Any], requested_source: str) -> bool: + current = str(session.get("idea") or "").strip() + requested = requested_source.strip() + if not current or not requested: + return False + return current != requested + + +def _llm_request_timeout_seconds() -> float: + raw = os.environ.get("VIMAX_LLM_REQUEST_TIMEOUT_SECONDS", "300") + try: + return max(1.0, float(raw)) + except ValueError: + return 300.0 + + +def _narrative_max_tokens() -> int: + raw = os.environ.get("VIMAX_NARRATIVE_MAX_TOKENS", "4096") + try: + return max(256, int(raw)) + except ValueError: + return 4096 + + +def _pipeline_progress(runtime: ToolRuntimeContext | None, session_id: str, *, scene_index: int | None = None): + if runtime is None: + return None + + def emit(stage: str, message: str, metadata: dict[str, Any] | None = None) -> None: + payload = dict(metadata or {}) + payload["session_id"] = session_id + if scene_index is not None: + payload["scene_index"] = scene_index + runtime.emit_progress(message, stage=stage, metadata=payload) + + return emit + + +def _build_chat_model() -> Any: + api_key = llm_api_key() + if not api_key: + raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for narrative planning") + return init_chat_model( + model=llm_model(), + model_provider=llm_model_provider(), + api_key=api_key, + base_url=llm_base_url(), + timeout=_llm_request_timeout_seconds(), + max_retries=0, + max_completion_tokens=_narrative_max_tokens(), + ) + + +def _build_image_generator() -> ImageGeneratorNanobananaYunwuAPI: + api_key = image_api_key() + if not api_key: + raise RuntimeError("VIMAX_IMAGE_API_KEY, VIMAX_LLM_API_KEY, or configs/agent.local.yaml image/llm api_key is required for image generation") + return ImageGeneratorNanobananaYunwuAPI(api_key=api_key, model=image_model(), base_url=image_base_url()) + + +def _build_video_generator() -> VideoGeneratorVeoYunwuAPI | VideoGeneratorOpenRouterAPI: + api_key = video_api_key() + if not api_key: + raise RuntimeError("VIMAX_VIDEO_API_KEY, VIMAX_LLM_API_KEY, or configs/agent.local.yaml video/llm api_key is required for video generation") + model = video_model() + base_url = video_base_url() + provider = video_provider().strip().lower() + if provider == "openrouter": + return VideoGeneratorOpenRouterAPI(api_key=api_key, model=model, base_url=base_url) + if provider == "yunwu": + return VideoGeneratorVeoYunwuAPI(api_key=api_key, t2v_model=model, ff2v_model=model, base_url=base_url) + raise RuntimeError(f"Unsupported video base_url for automatic provider matching: {base_url}") + + +class _IdentityRewriter: + async def __call__(self, prompt: str) -> str: + return prompt + + +def _build_embedding_model() -> Any: + api_key = embedding_api_key() + base_url = embedding_base_url() + provider = embedding_model_provider().strip().lower() + if not api_key or not base_url: + raise RuntimeError("VIMAX_EMBEDDING_API_KEY or configs/agent.local.yaml embedding api_key/base_url is required for novel planning") + if provider != "openai": + raise RuntimeError(f"Unsupported embedding model_provider: {provider}") + return OpenAIEmbeddings(model=embedding_model(), api_key=api_key, base_url=base_url) + + +def _build_reranker() -> RerankerBgeSiliconapi: + api_key = reranker_api_key() + base_url = reranker_base_url() + if not api_key or not base_url: + raise RuntimeError("VIMAX_RERANKER_API_KEY or configs/agent.local.yaml reranker api_key/base_url is required for novel planning") + return RerankerBgeSiliconapi(api_key=api_key, base_url=base_url, model=reranker_model()) + + +def _build_novel_pipeline(working_dir: Path) -> Novel2MoviePipeline: + api_key = llm_api_key() + if not api_key: + raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for novel planning") + base_url = llm_base_url() + model = llm_model() + dummy = _UnavailableGenerator() + return Novel2MoviePipeline( + novel_compressor=NovelCompressor(api_key=api_key, base_url=base_url, chat_model=model), + event_extractor=EventExtractor(api_key=api_key, base_url=base_url, chat_model=model), + embeddings=_build_embedding_model(), + rerank_model=_build_reranker(), + scene_extractor=SceneExtractor(api_key=api_key, base_url=base_url, chat_model=model), + global_information_planner=GlobalInformationPlanner(api_key=api_key, base_url=base_url, chat_model=model), + image_generator=dummy, + rewriter=_IdentityRewriter(), + script2video_pipeline=dummy, + working_dir=str(working_dir), + ) + + +def _build_novel_render_pipeline(working_dir: Path, chat_model: Any, image_generator: Any, video_generator: Any) -> Novel2MoviePipeline: + api_key = llm_api_key() + if not api_key: + raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for novel rendering") + base_url = llm_base_url() + model = llm_model() + script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(working_dir / "videos")) + return Novel2MoviePipeline( + novel_compressor=NovelCompressor(api_key=api_key, base_url=base_url, chat_model=model), + event_extractor=EventExtractor(api_key=api_key, base_url=base_url, chat_model=model), + embeddings=_build_embedding_model(), + rerank_model=_build_reranker(), + scene_extractor=SceneExtractor(api_key=api_key, base_url=base_url, chat_model=model), + global_information_planner=GlobalInformationPlanner(api_key=api_key, base_url=base_url, chat_model=model), + image_generator=image_generator, + rewriter=_IdentityRewriter(), + script2video_pipeline=script_pipeline, + working_dir=str(working_dir), + ) + + +def _unwrap_retry_error(exc: Exception) -> Exception: + if isinstance(exc, RetryError): + try: + return exc.last_attempt.exception() or exc + except Exception: + return exc + return exc + + +def _is_retryable_render_error(exc: Exception) -> bool: + text = str(exc).lower() + if isinstance(exc, AttributeError): + return False + if "http 403" in text or "key limit exceeded" in text or "quota" in text: + return False + return True + + +def _sanitize_error_text(text: str) -> str: + sanitized = text + for marker in ("workspaces/default/keys/",): + if marker in sanitized: + prefix, rest = sanitized.split(marker, 1) + key_id = [] + for char in rest: + if char.isalnum() or char in "-_": + key_id.append(char) + continue + break + sanitized = prefix + marker + "" + rest[len(key_id):] + if "sk-" in sanitized: + prefix, rest = sanitized.split("sk-", 1) + token = [] + for char in rest: + if char.isalnum() or char in "-_": + token.append(char) + continue + break + sanitized = prefix + "sk-" + rest[len(token):] + return sanitized + + +def _write_render_status(working_dir: Path, *, status: str, payload: dict[str, Any]) -> None: + working_dir.mkdir(parents=True, exist_ok=True) + event = { + "timestamp": datetime.now().isoformat(timespec="seconds"), + "status": status, + **payload, + } + (working_dir / "render_status.json").write_text(json.dumps(event, ensure_ascii=False, indent=2), encoding="utf-8") + with (working_dir / "render_events.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, ensure_ascii=False) + "\n") + + +def _write_characters_if_missing(path: Path, characters: list[CharacterInScene]) -> None: + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps([character.model_dump() for character in characters], ensure_ascii=False, indent=2), encoding="utf-8") + + +def _load_characters(path: Path) -> list[CharacterInScene]: + return [CharacterInScene.model_validate(item) for item in json.loads(path.read_text(encoding="utf-8"))] + + +def _load_script_text(working_dir: Path) -> str: + script_text = working_dir / "script2video" / "script.txt" + if script_text.exists(): + return script_text.read_text(encoding="utf-8") + idea_script = working_dir / "idea2video" / "script.json" + if idea_script.exists(): + payload = json.loads(idea_script.read_text(encoding="utf-8")) + return json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload + story = working_dir / "idea2video" / "story.txt" + if story.exists(): + return story.read_text(encoding="utf-8") + return "" + + +def _resolve_artifact_path(working_dir: Path, revision_target: str) -> Path: + rel = Path(revision_target) + if rel.is_absolute(): + raise ValueError(f"revision_target must be relative to session working_dir: {revision_target}") + path = (working_dir / rel).resolve() + if path != working_dir and working_dir not in path.parents: + raise ValueError(f"revision_target escapes session working_dir: {revision_target}") + return path + + +async def _revise_artifact_with_llm(chat_model: Any, target: str, current_text: str, instruction: str) -> str: + prompt = ( + "Revise this ViMax structured artifact exactly as requested. " + "Return only the complete replacement file content, with no Markdown fences or explanation. " + "If the file is JSON, preserve valid JSON and the existing schema shape.\n\n" + f"Target: {target}\n" + f"Revision instruction: {instruction}\n\n" + "Current file content:\n" + f"{current_text}" + ) + if hasattr(chat_model, "ainvoke"): + response = await chat_model.ainvoke(prompt) + elif hasattr(chat_model, "invoke"): + response = chat_model.invoke(prompt) + else: + raise RuntimeError("chat_model does not support invoke/ainvoke for revision mode") + content = getattr(response, "content", response) + if isinstance(content, list): + content = "".join(str(item.get("text", item)) if isinstance(item, dict) else str(item) for item in content) + return _strip_markdown_fences(str(content).strip()) + + +def _strip_markdown_fences(text: str) -> str: + if not text.startswith("```"): + return text + lines = text.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + return "\n".join(lines).strip() + + +def _stale_keys_for_revision(target: str) -> list[str]: + if "storyboard.json" in target: + return ["shot_descriptions", "camera_tree", "frames", "clips", "final_video"] + if "shot_description.json" in target: + return ["frames", "clips", "final_video"] + if "camera_tree.json" in target: + return ["frames", "clips", "final_video"] + if target.endswith("script.json") or target.endswith("story.txt"): + return ["storyboard", "shot_descriptions", "camera_tree", "frames", "clips", "final_video"] + if target.endswith("characters.json"): + return ["storyboard", "shot_descriptions", "frames", "clips", "final_video"] + return ["frames", "clips", "final_video"] + + +def _ready_for_render(checklist: dict[str, bool]) -> bool: + return _idea_mode_ready(checklist) or _script_mode_ready(checklist) or _novel_mode_ready(checklist) + + +def _missing_render_dependencies(checklist: dict[str, bool]) -> list[str]: + if _ready_for_render(checklist): + return [] + 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"] + script_required = ["script2video/script.txt", "script2video/characters.json", "script2video/storyboard.json", "script2video/shots/*/shot_description.json", "script2video/camera_tree.json"] + 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"] + 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)] + + +def _idea_mode_ready(checklist: dict[str, bool]) -> bool: + 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")) + + +def _novel_text_ready(checklist: dict[str, bool]) -> bool: + return _novel_mode_ready(checklist) + + +def _novel_mode_ready(checklist: dict[str, bool]) -> bool: + 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")) + + +def _script_mode_ready(checklist: dict[str, bool]) -> bool: + 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")) diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..7273f24 --- /dev/null +++ b/agents/__init__.py @@ -0,0 +1,15 @@ +from .screenwriter import Screenwriter +from .storyboard_artist import StoryboardArtist +from .camera_image_generator import CameraImageGenerator +from .character_extractor import CharacterExtractor +from .character_portraits_generator import CharacterPortraitsGenerator +from .reference_image_selector import ReferenceImageSelector + +__all__ = [ + "Screenwriter", + "StoryboardArtist", + "CameraImageGenerator", + "CharacterExtractor", + "CharacterPortraitsGenerator", + "ReferenceImageSelector", +] \ No newline at end of file diff --git a/agents/best_image_selector.py b/agents/best_image_selector.py new file mode 100644 index 0000000..936e99c --- /dev/null +++ b/agents/best_image_selector.py @@ -0,0 +1,147 @@ +import logging +from typing import List, Tuple +from pydantic import BaseModel, Field +from tenacity import retry, stop_after_attempt +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models import init_chat_model +from utils.image import image_path_to_b64 + + + +system_prompt_template_select_most_consistent_image = \ +""" +[Role] +You are a professional visual assessment expert. Your expertise includes identifying Character Consistency and Spatial Consistency between candidate image and reference image, and assessing semantic consistency between candidate image and text description. + +[Task] +Based on the reference image provided by the user, the text description of the target image, and several candidate images, evaluate which candidate image performs best in the following aspects: +- Character Consistency: Whether the character features (a. gender, b.ethnicity, c.age, d.facial features, e.body shape, f.outlook, g. hairstyle) in the candidate image align with those of the character in the reference image. +- Spatial Consistency: Whether the relative positions between characters (e.g. Character A is on the left, character B is on the right, scene layout, perspective, and other spatial relationships) in the candidate image are consistent with those in the reference image. +- Description Accuracy: Whether the candidate image accurately reflects the content described in the text (Note: The text description describes the target image we want, which is not an editing instruction). + +[Input] +The user will provide the following content: +- Reference images: These include images of characters or other perspectives, each along with a brief text description. For example, "Reference Image 0: A young girl with long brown hair wearing a red dress." then follow the corresponding image. The index starts from 0. +- Candidate images: The candidate images to be evaluated. For example, "Generated Image 0", then follow a generated image. The index starts from 0. +- Text description for target image: This describes what the generated image should contain. It is enclosed and tags. + +[Output] +{format_instructions} + +[Guidelines] +- Prioritize Character Consistency: Ensure that the characters in the generated image are highly consistent with those in the reference image in terms of visual features (e.g., a. gender b.ethnicity, c.age, d.facial features, e.body shape, f.outlook, g. hairstyle etc.). +- Focus on Spatial Consistency: Verify whether the relative positions of characters, object arrangements, and perspectives align logically with the reference image (e.g., if Character A is on the left and Character B is on the right in the reference image, the generated image should not reverse this). +- Strictly Compare with Text Description: The generated image must adhere to key elements in the text description (e.g., actions, scenes, objects, etc.), while disregarding parts related to editing instructions (as the input description reflects the expected outcome rather than directives). +- If multiple images partially meet the criteria, select the one with the highest overall consistency; if none are ideal, choose the relatively best option and explain its shortcomings. +- Ensure the key elements described in the text are present in the selected image. +- Avoid subjective preferences; base all analysis on objective comparisons. +- Prioritize images without white borders, black edges, or any additional framing. +""" + +human_prompt_template_select_most_consistent_image = \ +""" + +{target_description} + +""" + + +class BestImageResponse(BaseModel): + best_image_index: int = Field( + ..., + description="The index of the best image." + ) + reason: str = Field( + ..., + description="The reason why the image is the best." + ) + + +class BestImageSelector: + def __init__( + self, + base_url: str, + api_key: str, + chat_model: str, + ): + + self.chat_model = init_chat_model( + model=chat_model, + model_provider="openai", + base_url=base_url, + api_key=api_key, + ) + + + @retry( + stop=stop_after_attempt(3), + after=lambda retry_state: logging.warning(f"Retrying best image selection due to {retry_state.outcome.exception()}"), + ) + async def __call__( + self, + reference_image_path_and_text_pairs: List[Tuple[str, str]], + target_description: str, + candidate_image_paths: List[str], + ) -> str: + """ + Args: + ref_image_path_and_text_pairs: + A list of tuples containing reference image paths and their descriptions. + + target_description: + The description of the target image. + + candidate_image_paths: + A list of paths to the candidate images to be evaluated. + """ + + if not candidate_image_paths: + logging.warning("No candidate images provided; skipping best image selection") + raise ValueError("No candidate images to select from") + + logging.info(f"Selecting the best image from candidates: {candidate_image_paths}") + + human_content = [] + for idx, (ref_image_path, text) in enumerate(reference_image_path_and_text_pairs): + human_content.append({ + "type": "text", + "text": f"Reference Image {idx}: {text}" + }) + human_content.append({ + "type": "image_url", + "image_url": {"url": image_path_to_b64(ref_image_path, mime=True)} + }) + + for idx, candidate_image_path in enumerate(candidate_image_paths): + human_content.append({ + "type": "text", + "text": f"Candidate Image {idx}" + }) + human_content.append({ + "type": "image_url", + "image_url": {"url": image_path_to_b64(candidate_image_path, mime=True)} + }) + human_content.append({ + "type": "text", + "text": human_prompt_template_select_most_consistent_image.format(target_description=target_description) + }) + + parser = PydanticOutputParser(pydantic_object=BestImageResponse) + + messages = [ + SystemMessage(content=system_prompt_template_select_most_consistent_image.format(format_instructions=parser.get_format_instructions())), + HumanMessage(content=human_content) + ] + + chain = self.chat_model | parser + + response = await chain.ainvoke(messages) + idx = response.best_image_index + if not isinstance(idx, int) or idx < 0 or idx >= len(candidate_image_paths): + logging.warning(f"Received invalid best_image_index={idx}; defaulting to 0") + idx = 0 + best_image_path = candidate_image_paths[idx] + logging.info(f"Best image selected: {best_image_path}") + logging.info(f"Selection reason: {response.reason}") + return best_image_path diff --git a/agents/camera_image_generator.py b/agents/camera_image_generator.py new file mode 100644 index 0000000..bc9b014 --- /dev/null +++ b/agents/camera_image_generator.py @@ -0,0 +1,272 @@ +import os +import logging +import cv2 +from typing import List, Tuple, Union, Optional +from pydantic import BaseModel, Field +from tenacity import retry, stop_after_attempt +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.output_parsers import PydanticOutputParser +from scenedetect import open_video, SceneManager, split_video_ffmpeg +from scenedetect.detectors import ContentDetector + +from interfaces import ShotDescription, ShotBriefDescription, Camera, ImageOutput, VideoOutput + + +from moviepy import VideoFileClip +from PIL import Image + + +system_prompt_template_select_reference_camera = \ +""" +[Role] +You are a professional video editing expert specializing in multi-camera shot analysis and scene structure modeling. You have deep knowledge of cinematic language, enabling you to understand shot sizes (e.g., wide shot, medium shot, close-up) and content inclusion relationships. You can infer hierarchical structures between camera positions based on corresponding shot descriptions. + +[Task] +Your task is to analyze the input camera position data to construct a "camera position tree". This tree structure represents a relationship where a parent camera's content encompasses that of a child camera. Specifically, you need to identify the parent camera for each camera position (if one exists) and determine the dependent shot indices (i.e., the specific shots within the parent camera's footage that contain the child camera's content). If a camera position has no parent, output None. + +[Input] +The input is a sequence of cameras. The sequence will be enclosed within and . +Each camera contains a sequence of shots filmed by the camera, which will be enclosed within and , where N is the index of the camera. + +Below is an example of the input format: + + + +Shot 0: Medium shot of the street. Alice and Bob are walking towards each other. +Shot 2: Medium shot of the street. Alice and Bob hug each other. + + +Shot 1: Close-up of the Alice's face. Her expression shifts from surprise to delight as she recognizes Bob. + + + + +[Output] +{format_instructions} + +[Guidelines] +- The language of all output values (not include keys) should be consistent with the language of the input. +- Content Inclusion Check: The parent camera should as fully as possible contain the child camera's content in certain shots (e.g., a parent medium two-shot encompasses a child over-the-shoulder reverse shot). Analyze shot descriptions by comparing keywords (e.g., characters, actions, setting) to ensure the parent shot's field of view covers the child shot's. +- Transition Smoothness Priority: Larger shot size as parent camera is preferred, such as Wide Shot -> Medium Shot or Medium Shot -> Close-up. The shot sizes of adjacent parent and child nodes should be as similar as possible. A direct transition from a long shot to a close-up is not allowed unless absolutely necessary. +- Temporal Proximity: Each camera is described by its corresponding first shot, and the parent camera is located based on the description of the first shot. The shot index of the parent camera should be as close as possible to the first shot index of the child camera. +- Logical Consistency: The camera tree should be acyclic, avoid circular dependencies. If a camera is contained by multiple potential parents, select the best match (based on shot size and content). If there is no suitable parent camera, output None. +- When a broader perspective is not available, choose the shot with the largest overlapping field of view as the parent (the one with the most information overlap), or a shot can also serve as the parent of a reverse shot. When two cameras can be the parent of each other, choose the one with the smaller index as the parent of the camera with the larger index. +- Only one camera can exist without a parent. +- When describing the elements lost in a shot, carefully compare the details between the parent shot and the child shot. For example, the parent shot is a medium shot of Character A and Character B facing each other (both in profile to the camera), while the child shot is a close-up of Character A (with Character A facing the camera directly). In this case, the child shot lacks the frontal view information of Character A. +- The first camera must be the root of the camera tree. +""" + + +human_prompt_template_select_reference_camera = \ +""" + +{camera_seq_str} + +""" + + +class CameraParentItem(BaseModel): + parent_cam_idx: Optional[int] = Field( + default=None, + description="The index of the parent camera. Set to None if the camera has no parent (e.g., for a root camera).", + examples=[0, 1, None], + ) + parent_shot_idx: Optional[int] = Field( + default=None, + description="The index of the dependent shot. Set to None if the camera has no parent (e.g., for a root camera).", + examples=[0, 3, None], + ) + reason: str = Field( + description="The reason for the selection of the parent camera. If the camera has no parent, it should explain why it's a root camera.", + examples=[ + "The parent shot's field of view covers the child shot's field of view (from medium shot to close-up)", + "The parent shot and the child shot have a shot/reverse shot relationship.", + "CAMERA_0 (Shot 0) establishes the entire scene and contains all characters and the setting. It is the root camera." # 补充 LLM 实际输出的例子 + ], + ) + is_parent_fully_covers_child: Optional[bool] = Field( + default=None, + description="Whether the parent camera fully covers the child camera's content. Set to None if the camera has no parent.", + examples=[True, False, None], + ) + missing_info: Optional[str] = Field( + default=None, + description="The missing elements in the child shot that are not covered by the parent shot. If the parent shot fully covers the child shot, set this to None.", + examples=[ + "The frontal view of Alice.", + None, + ], + ) + +class CameraTreeResponse(BaseModel): + camera_parent_items: List[Optional[CameraParentItem]] = Field( + description="The parent camera items for each camera. If a camera has no parent, set this to None. The length of the list should be the same as the number of cameras.", + ) + + + +class CameraImageGenerator: + + def __init__( + self, + chat_model, + image_generator, + video_generator, + ): + self.chat_model = chat_model + self.image_generator = image_generator + self.video_generator = video_generator + + + async def construct_camera_tree( + self, + cameras: List[Camera], + shot_descs: List[Union[ShotDescription, ShotBriefDescription]], + ) -> List[Camera]: + parser = PydanticOutputParser(pydantic_object=CameraTreeResponse) + shot_desc_by_idx = {shot.idx: shot for shot in shot_descs} + + camera_seq_str = "\n" + for cam in cameras: + camera_seq_str += f"\n" + for shot_idx in cam.active_shot_idxs: + shot_desc = shot_desc_by_idx.get(shot_idx) + if shot_desc is None: + raise ValueError(f"Camera {cam.idx} references missing shot {shot_idx}") + camera_seq_str += f"Shot {shot_idx}: {shot_desc.visual_desc}\n" + camera_seq_str += f"\n" + camera_seq_str += "" + + messages = [ + SystemMessage(content=system_prompt_template_select_reference_camera.format(format_instructions=parser.get_format_instructions())), + HumanMessage(content=human_prompt_template_select_reference_camera.format(camera_seq_str=camera_seq_str)), + ] + + chain = self.chat_model | parser + response: CameraTreeResponse = await chain.ainvoke(messages) + parent_items = response.camera_parent_items + if len(parent_items) != len(cameras): + raise ValueError(f"Camera tree response length mismatch: expected {len(cameras)}, got {len(parent_items)}") + + valid_camera_idxs = {cam.idx for cam in cameras} + valid_shot_idxs = set(shot_desc_by_idx) + parent_by_camera = {} + for cam, parent_cam_item in zip(cameras, parent_items): + parent_cam_idx = parent_cam_item.parent_cam_idx if parent_cam_item is not None else None + parent_shot_idx = parent_cam_item.parent_shot_idx if parent_cam_item is not None else None + if parent_cam_idx is not None and parent_cam_idx not in valid_camera_idxs: + raise ValueError(f"Camera {cam.idx} has invalid parent camera {parent_cam_idx}") + if parent_cam_idx == cam.idx: + raise ValueError(f"Camera {cam.idx} cannot be its own parent") + if parent_shot_idx is not None and parent_shot_idx not in valid_shot_idxs: + raise ValueError(f"Camera {cam.idx} has invalid parent shot {parent_shot_idx}") + parent_by_camera[cam.idx] = parent_cam_idx + + for cam in cameras: + seen = set() + current = cam.idx + while parent_by_camera.get(current) is not None: + current = parent_by_camera[current] + if current in seen: + raise ValueError(f"Camera tree contains a cycle involving camera {cam.idx}") + seen.add(current) + + for cam, parent_cam_item in zip(cameras, parent_items): + cam.parent_cam_idx = parent_cam_item.parent_cam_idx if parent_cam_item is not None else None + cam.parent_shot_idx = parent_cam_item.parent_shot_idx if parent_cam_item is not None else None + cam.reason = parent_cam_item.reason if parent_cam_item is not None else None + cam.is_parent_fully_covers_child = parent_cam_item.is_parent_fully_covers_child if parent_cam_item is not None else None + cam.missing_info = parent_cam_item.missing_info if parent_cam_item is not None else None + return cameras + + + async def generate_transition_video( + self, + first_shot_visual_desc: str, + second_shot_visual_desc: str, + first_shot_ff_path: str, + progress=None, + ) -> VideoOutput: + + prompt = f"Two shots. The transition between the shots is a cut to. The style of the two shots should be consistent." + prompt += f"\nThe first shot description: {first_shot_visual_desc}." + prompt += f"\nThe second shot description: {second_shot_visual_desc}." + reference_image_paths = [first_shot_ff_path] + video_output = await self.video_generator.generate_single_video( + prompt=prompt, + reference_image_paths=reference_image_paths, + progress=progress, + ) + return video_output + + + def get_new_camera_image( + self, + transition_video_path: str, + ) -> ImageOutput: + video = open_video(transition_video_path) + scene_manager = SceneManager() + scene_manager.add_detector(ContentDetector()) + scene_manager.detect_scenes(video, show_progress=False) + scene_list = scene_manager.get_scene_list() + output_dir = os.path.join(os.path.dirname(transition_video_path), "cache") + os.makedirs(output_dir, exist_ok=True) + split_video_ffmpeg(transition_video_path, scene_list, output_dir, show_progress=True) + + + video_name = os.path.basename(transition_video_path).split('.')[0] + second_video_path = os.path.join(output_dir, f"{video_name}-Scene-002.mp4") + if os.path.exists(second_video_path): + # use first frame of second shot as new camera image + clip = VideoFileClip(second_video_path) + ff = clip.get_frame(0) + ff = Image.fromarray(ff.astype('uint8'), 'RGB') + return ImageOutput(fmt="pil", ext="png", data=ff) + else: + # use last frame of transition video to instead + clip = VideoFileClip(transition_video_path) + lf_time = clip.duration - (1 / clip.fps) + lf_time = max(0, lf_time) + lf = clip.get_frame(lf_time) + lf = Image.fromarray(lf.astype('uint8'), 'RGB') + return ImageOutput(fmt="pil", ext="png", data=lf) + + + async def generate_first_frame( + self, + shot_desc: ShotDescription, + character_portrait_path_and_text_pairs: List[Tuple[str, str]], + ) -> ImageOutput: + prompt = "" + reference_image_paths = [] + for i,(path, text )in enumerate(character_portrait_path_and_text_pairs): + prompt += f"Image {i}: {text}\n" + reference_image_paths.append(path) + prompt += f"Generate an image based on the following description: {shot_desc.ff_desc}." + image_output = await self.image_generator.generate_single_image( + prompt=prompt, + reference_image_paths=reference_image_paths, + size="1600x900", + ) + return image_output + + + +def _validate_camera_tree(cameras: List[Camera]) -> None: + """Reject parent assignments that would deadlock frame generation.""" + by_idx = {cam.idx: cam for cam in cameras} + for cam in cameras: + if cam.parent_cam_idx is None: + continue + if cam.parent_cam_idx == cam.idx: + raise ValueError(f"Camera {cam.idx} lists itself as its parent.") + if cam.parent_cam_idx not in by_idx: + raise ValueError(f"Camera {cam.idx} references unknown parent camera {cam.parent_cam_idx}.") + for cam in cameras: + seen = set() + current = cam + while current.parent_cam_idx is not None: + if current.idx in seen: + raise ValueError(f"Cycle detected in camera parent graph involving camera {current.idx}.") + seen.add(current.idx) + current = by_idx[current.parent_cam_idx] diff --git a/agents/character_extractor.py b/agents/character_extractor.py new file mode 100644 index 0000000..437c011 --- /dev/null +++ b/agents/character_extractor.py @@ -0,0 +1,89 @@ +import logging +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models.base import BaseChatModel +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from typing import List +from tenacity import retry, stop_after_attempt +from interfaces import CharacterInScene +from langchain_core.messages import HumanMessage, SystemMessage + +from utils.retry import after_func + + +system_prompt_template_extract_characters = \ +""" +[Role] +You are a top-tier movie script analysis expert. + +[Task] +Your task is to analyze the provided script and extract all relevant character information. + +[Input] +You will receive a script enclosed within . + +Below is a simple example of the input: + + + +[Output] +{format_instructions} + + +[Guidelines] +- Ensure that the language of all output values(not include keys) matches that used in the script. +- Group all names referring to the same entity under one character. Select the most appropriate name as the character's identifier. If the person is a real famous person, the real person's name should be retained (e.g., Elon Musk, Bill Gates) +- If the character's name is not mentioned, you can use reasonable pronouns to refer to them, including using their occupation or notable physical traits. For example, "the young woman" or "the barista". +- For background characters in the script, you do not need to consider them as individual characters. +- If a character's traits are not described or only partially outlined in the script, you need to design plausible features based on the context to make their characteristics more complete and detailed, ensuring they are vivid and evocative. +- In static features, you need to describe the character's physical appearance, physique, and other relatively unchanging features. In dynamic features, you need to describe the character's attire, accessories, key items they carry, and other easily changeable features. +- Don't include any information about the character's personality, role, or relationships with others in either static or dynamic features. +- When designing character features, within reasonable limits, different character appearances should be made more distinct from each other. +- The description of characters should be detailed, avoiding the use of abstract terms. Instead, employ descriptions that can be visualized—such as specific clothing colors and concrete physical traits (e.g., large eyes, a high nose bridge). +""" + +human_prompt_template_extract_characters = \ +""" + +""" + + +class ExtractCharactersResponse(BaseModel): + characters: List[CharacterInScene] = Field( + ..., description="A list of characters extracted from the script." + ) + + + +class CharacterExtractor: + def __init__( + self, + chat_model, + ): + self.chat_model = chat_model + + @retry( + stop=stop_after_attempt(3), + after=after_func, + ) + async def extract_characters(self, script: str) -> List[CharacterInScene]: + + parser = PydanticOutputParser(pydantic_object=ExtractCharactersResponse) + + messages = [ + SystemMessage(content=system_prompt_template_extract_characters.format(format_instructions=parser.get_format_instructions())), + HumanMessage(content=human_prompt_template_extract_characters.format(script=script)), + ] + + chain = self.chat_model | parser + + response: ExtractCharactersResponse = await chain.ainvoke(messages) + + return response.characters + diff --git a/agents/character_portraits_generator.py b/agents/character_portraits_generator.py new file mode 100644 index 0000000..9c38f02 --- /dev/null +++ b/agents/character_portraits_generator.py @@ -0,0 +1,87 @@ +import logging +import os +import asyncio +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models.base import BaseChatModel +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from typing import List, Optional, Dict +from interfaces import CharacterInScene, ImageOutput +from langchain_core.messages import HumanMessage, SystemMessage + + + +prompt_template_front = \ +""" +Generate a full-body, front-view portrait of character {identifier} based on the following description, with a pure white background. Use a wide 16:9 landscape canvas, not a vertical portrait canvas. The character should be centered in the image, occupying the middle of the wide frame with enough horizontal empty space. Gazing straight ahead. Standing with arms relaxed at sides. Natural expression. +Features: {features} +Style: {style} +""" + +prompt_template_side = \ +""" +Generate a full-body, side-view portrait of character {identifier} based on the provided front-view portrait, with a pure white background. Use a wide 16:9 landscape canvas, not a vertical portrait canvas. The character should be centered in the image, occupying the middle of the wide frame with enough horizontal empty space. Facing left. Standing with arms relaxed at sides. +""" + +prompt_template_back = \ +""" +Generate a full-body, back-view portrait of character {identifier} based on the provided front-view portrait, with a pure white background. Use a wide 16:9 landscape canvas, not a vertical portrait canvas. The character should be centered in the image, occupying the middle of the wide frame with enough horizontal empty space. No facial features should be visible. +""" + + +class CharacterPortraitsGenerator: + def __init__( + self, + image_generator, + ): + self.image_generator = image_generator + + + async def generate_front_portrait( + self, + character: CharacterInScene, + style: str, + ) -> ImageOutput: + features = "(static) " + character.static_features + "; (dynamic) " + character.dynamic_features + prompt = prompt_template_front.format( + identifier=character.identifier_in_scene, + features=features, + style=style, + ) + image_output = await self.image_generator.generate_single_image( + prompt=prompt, + # size="512x512", + ) + return image_output + + async def generate_side_portrait( + self, + character: CharacterInScene, + front_image_path: str, + ) -> ImageOutput: + prompt = prompt_template_side.format( + identifier=character.identifier_in_scene, + ) + image_output = await self.image_generator.generate_single_image( + prompt=prompt, + reference_image_paths=[front_image_path], + # size="1024x1024", + ) + return image_output + + + async def generate_back_portrait( + self, + character: CharacterInScene, + front_image_path: str, + ) -> ImageOutput: + prompt = prompt_template_back.format( + identifier=character.identifier_in_scene, + ) + image_output = await self.image_generator.generate_single_image( + prompt=prompt, + reference_image_paths=[front_image_path], + # size="512x512", + ) + return image_output \ No newline at end of file diff --git a/agents/event_extractor.py b/agents/event_extractor.py new file mode 100644 index 0000000..58fbe62 --- /dev/null +++ b/agents/event_extractor.py @@ -0,0 +1,155 @@ +import os +import logging +import asyncio +from typing import List +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from tenacity import retry, stop_after_attempt + +from interfaces import Event + +system_prompt_template_extract_events = \ +""" +You are a highly skilled Literary Analyst AI. Your expertise is in narrative structure, plot deconstruction, and thematic analysis. You meticulously read and interpret prose to break down a story into its fundamental sequential events. + +**TASK** +Extract the next event from the provided novel, following the sequence of the story and building upon the partially extracted events. + +**INPUT** +1. The full text of the novel, which is enclosed within and tags +2. A sequence of already-extracted events (in order), which is enclosed within and tags. The sequence may be empty. Each event contains multiple processes and constitutes a complete causal chain. + +Below is an example input: + + +The night was as dark as ink when the piercing alarm of the city museum suddenly shattered the silence. A thief, moving with phantom-like agility, had just pried open the display case and snatched the blue gem known as the "Heart of the Ocean" when the blaring alarm echoed through the hall. +... (more novel text) ... + + + + +Description: A thief who stole a gem from a museum was caught after a rooftop chase with guards, and the gem was recovered. +Process Chain: +- A thief steals a gem from a museum, triggering the alarm. Guards notice and begin the chase. +- The thief rushes out the museum's back door and dashes through narrow alleys, with guards closely pursuing and calling for backup. +- ... (more processes) ... + + +Description: ... (more description) ... +Process Chain: +- ... (more processes) ... + + + + +**OUTPUT** +{format_instructions} + +**GUIDELINES** +1. Focus on events that are critical to the plot, character development, or thematic depth. +2. Ensure the event is logically distinct from previous and subsequent events. +3. If the event spans multiple scenes, unify them under a single dramatic goal. For example, a chase sequence might begin in a city market, continue through back alleys, and conclude on a rooftop—all comprising a single event because they collectively achieve the dramatic purpose of "the protagonist evading capture." +4. Maintain objectivity: describe events based on the text without interpretation or judgment. +5. For the process field, provide a detailed, step-by-step account of the event's progression, including key actions, decisions, and turning points. Each step should be clear and concise, illustrating how the event unfolds over time. +Below is an example: +Timeframe: The following morning, after acquiring the information about the Temple. +Characters: Elara (protagonist) and Kaelen (her rival treasure hunter). +Cause: Both seek the same artifact and are determined to reach it first. +Process: The event begins with Elara hastily purchasing supplies in the port town (scene 1), where she spots Kaelen already hiring a crew, raising the stakes. It continues as she races to secure her own ship and captain, negotiating fiercely under time pressure (scene 2). The event culminates in a direct confrontation on the docks (scene 3), where Kaelen attempts to sabotage her vessel, leading to a brief but intense sword fight between the two rivals. +Outcome: Elara successfully defends her ship and sets sail, but the conflict solidifies a bitter personal rivalry with Kaelen, ensuring their race to the temple will be fraught with direct opposition and danger. +6. Every detail in your event description must be directly supported by the input novel. Do not add, assume, or invent any information. +7. The language of outputs in values should be same as the input text. +""" + +human_prompt_template_extract_next_event = \ +""" + +{novel_text} + + + +{extracted_events} + +""" + + + +class EventExtractor: + def __init__( + self, + api_key: str, + base_url: str, + chat_model: str, + ): + self.chat_model = init_chat_model( + model=chat_model, + model_provider="openai", + api_key=api_key, + base_url=base_url, + ) + self.parser = PydanticOutputParser(pydantic_object=Event) + + + # Cap on extracted events: is_last is asserted by the LLM only, so without a + # bound a model that never sets it would loop (and spend tokens) forever. + max_events = 50 + + def __call__( + self, + novel_text: str, + ): + logging.info("Extracting events from novel...") + + events = [] + while True: + if len(events) >= self.max_events: + raise RuntimeError( + f"Event extraction exceeded the maximum of {self.max_events} events " + "without an is_last marker; aborting to avoid unbounded LLM calls." + ) + event = self.extract_next_event(novel_text, events) + + events.append(event) + logging.info(f"Extracted event: \n{event}") + if event.is_last: + break + + return events + + + @retry( + stop=stop_after_attempt(3), + after=lambda retry_state: logging.warning(f"Retrying extract_next_event due to error: {retry_state.outcome.exception()}"), + ) + def extract_next_event( + self, + novel_text: str, + extracted_events: List[Event] + ) -> Event: + + extracted_events_str = "\n\n".join([str(e) for e in extracted_events]) + + messages = [ + SystemMessage( + content=system_prompt_template_extract_events.format(format_instructions=self.parser.get_format_instructions()), + ), + HumanMessage( + content=human_prompt_template_extract_next_event.format( + novel_text=novel_text, + extracted_events=extracted_events_str, + ) + ) + ] + + chain = self.chat_model | self.parser + + event: Event = chain.invoke(messages) + + assert event.index == len(extracted_events), f"Extracted event index {event.index} does not match the expected index {len(extracted_events)}" + + return event + + + diff --git a/agents/global_information_planner.py b/agents/global_information_planner.py new file mode 100644 index 0000000..d3e07d5 --- /dev/null +++ b/agents/global_information_planner.py @@ -0,0 +1,368 @@ +import os +import logging +import asyncio +from typing import List, Tuple, Dict, Optional +from langchain_core.messages import HumanMessage, SystemMessage +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from langchain.output_parsers import PydanticOutputParser +from interfaces import Event, Scene +from interfaces import CharacterInScene, CharacterInEvent, CharacterInNovel +from tenacity import retry, stop_after_attempt + + +system_prompt_template_merge_characters_across_scenes_in_event = \ +""" +You are an expert script analysis and character fusion specialist. Your role is to intelligently analyze multiple script scenes, identify characters that represent the same entity across different scenes, and merge them into a unified character list with consistent identifiers. + +**TASK** +Process the input scenes, each containing a script and characters with their names and features. Identify and merge characters that are logically the same across scenes, even if they have different names or slight variations in description. Output a consolidated list of characters for the entire event. Each character in the list must have a unique identifier, along with the scene numbers where they appear and the name used in each scene. You also need to aggregate the static features of the same characters together. + +**INPUT** +A sequence of scenes. Each scene is enclosed within and tags, where N is the scene number(starting from 0). +Each scene includes a screnplay script and a sequence of character names. +The screenplay script is enclosed within and tags. +The sequence of character is enclosed within and tags. Each character in the list is enclosed within and tags, where M is the character number(starting from 0). + +Below is an example of one scene: + + + + +John enters the room and sees Mary. +John: Hi Mary, how are you? +Mary: I'm good, John. Thanks for asking! + + + + + +John [visible] +static features: John is a tall man with short black hair and brown eyes. +dynamic features: Wearing a blue shirt and black pants. + + + +Mary [visible] +static features: Mary is a young woman with long brown hair and green eyes. +dynamic features: Wearing a floral dress and a denim jacket. + + + + + + + + +**OUTPUT** +{format_instructions} + +**GUIDELINES** +1. Character Fusion: Analyze contextual clues (e.g., dialogue style, role in plot, relationships, descriptions) to determine if characters from different scenes are the same person, even if names vary. +2. Unique Identifier: Assign a consistent, unique ID (e.g., primary/canonical name) to each merged character. Use the most frequent or contextually appropriate name as the identifier, if possible. +3. Scene Mapping: For each character, list all scenes they appear in and the exact name used in each scene. +4. Completeness: Ensure all characters from all scenes are included in the final list. No duplicate, omitted, or extraneous characters. +5. If a character undergoes significant changes across different scenes, it is necessary to split them into separate roles. For example, if Character A is a child in Scene 0 but an adult in Scene 1, they should be divided into two distinct characters (meaning two different actors are required to portray them). +6. The language of outputs in values should be same as the input text. +""" + + +human_prompt_template_merge_characters_across_scenes_in_event = \ +""" +{scenes_sequence} +""" + +class MergeCharactersAcrossScenesInEventResponse(BaseModel): + characters: List[CharacterInEvent] = Field( + description="List of merged characters with their identifiers", + ) + + + + +system_prompt_template_merge_characters_to_existing_characters_in_novel = \ +""" +You are an information integration expert skilled in accurately identifying, matching, and merging character information. Your responsibility is to ensure consistency in character attributes and efficiently maintain and update the global character list. + +**TASK** +Merge the character list extracted from the current event (which may include new or existing characters) into the global character list. For existing characters, ensure their feature descriptions remain consistent; for new characters, add them to the global list. + +**INPUT** +1. Existing Characters in the Novel: A list of characters already present in the novel, each with a unique index, identifier, and static features. The list is enclosed within and tags. Each character in the list is enclosed within and tags, where P is the character number(starting from 0). +2. Characters in the Current Event: A list of characters identified in the current event, each with an index, identifier, active scenes, and static features. The list is enclosed within and tags. Each character in the list is enclosed within and tags, where Q is the character number(starting from 0). + + +**OUTPUT** +{format_instructions} + +**GUIDELINES** +1. Feature Consistency: Strictly compare the features of the current event characters with those of existing characters. Some character's identifier may be the same as existing role identifier, but their features differ, such as youth and old age. You need to distinguish them as two separate characters. +2. Efficient Merging: Avoid duplicate characters to ensure the list remains concise. +3. Feature Update: If an existing character's features are expanded or modified based on new information from the current event, update their description accordingly. +""" + +human_prompt_template_merge_characters_to_existing_characters_in_novel = \ +""" + +{existing_characters_in_novel} + + + +{characters_in_event} + +""" + + +class CharacterForMergingToNovel(BaseModel): + index_in_event: int = Field( + description="The index of the character in the list of characters in the current event.", + examples=[0, 1, 2], + ) + index_in_novel: int = Field( + description="The index of the character in the list of existing characters in the novel. If this is a new character, set it to -1.", + examples=[0, 7, -1], + ) + identifier_in_novel: str = Field( + description="The unique identifier for the character in the novel. If this is a new character, ensure the name does not conflict with existing characters. If this is not a new character, this should match the identifier in the existing characters list.", + examples=["Alice", "Bob the Builder"], + ) + modified_features: str = Field( + description="The modified static features of the character after merging. If the character is new, this should be the full static features. If the character is existing and their features are expanded or modified, this should be filled in the complete modified features. If the character is existing and their features remain unchanged, this should be the same as the existing character's static features.", + ) + +class MergeCharactersToExistingCharactersInNovelResponse(BaseModel): + characters: List[CharacterForMergingToNovel] = Field( + description="List of characters in the event with their corresponding index in the existing characters in the novel. If the character is new, the index_in_novel should be -1. The number of characters in this list should be the same as the number of characters in the event.", + ) + + + +class GlobalInformationPlanner: + def __init__( + self, + api_key: str, + base_url: str, + chat_model: str, + ): + self.chat_model = init_chat_model( + model=chat_model, + model_provider="openai", + api_key=api_key, + base_url=base_url, + ) + + @retry( + stop=stop_after_attempt(3), + after=lambda retry_state: logging.warning(f"Retrying due to {retry_state.outcome.exception()}"), + ) + async def merge_characters_across_scenes_in_event( + self, + event_idx: int, + scenes: List[Scene], # Scene.characters is List[CharacterInScene] + ) -> List[CharacterInEvent]: + scenes_sequence_str = "" + for scene in scenes: + scene_str = f"\n" + scene_str += "\n" + scene_str += scene.script + "\n" + scene_str += "\n\n" + scene_str += "\n" + for character in scene.characters: + scene_str += f"\n" + scene_str += str(character) + scene_str += f"\n" + scene_str += "\n" + scene_str += f"\n" + scenes_sequence_str += scene_str + + parser = PydanticOutputParser(pydantic_object=MergeCharactersAcrossScenesInEventResponse) + + messages = [ + SystemMessage( + content=system_prompt_template_merge_characters_across_scenes_in_event.format( + format_instructions=parser.get_format_instructions(), + ), + ), + HumanMessage( + content=human_prompt_template_merge_characters_across_scenes_in_event.format( + scenes_sequence=scenes_sequence_str, + ) + ) + ] + + chain = self.chat_model | parser + response: MergeCharactersAcrossScenesInEventResponse = await chain.ainvoke(messages) + characters_in_event = response.characters + + # check the output is valid + flags = [{c.identifier_in_scene: False for c in s.characters} for s in scenes] + + # check if all character identifiers can be found in the scenes + for character in characters_in_event: + for scene_idx, identifier_in_scene in character.active_scenes.items(): + if identifier_in_scene not in [c.identifier_in_scene for c in scenes[scene_idx].characters]: + raise ValueError(f"Character {identifier_in_scene} not found in scene {scene_idx} of event {event_idx}") + else: + flags[scene_idx][identifier_in_scene] = True + + # check if all characters are included + for scene_idx, flag in enumerate(flags): + for identifier_in_scene, included in flag.items(): + if not included: + raise ValueError(f"Character {identifier_in_scene} in scene {scene_idx} of event {event_idx} not included in the merged characters") + + return characters_in_event + + @retry( + stop=stop_after_attempt(3), + after=lambda retry_state: logging.warning(f"Retrying due to {retry_state.outcome.exception()}"), + ) + def merge_characters_to_existing_characters_in_novel( + self, + event_idx: int, + existing_characters_in_novel: List[CharacterInNovel], + characters_in_event: List[CharacterInEvent], + ) -> List[CharacterInNovel]: + existing_characters_str = "" + for character in existing_characters_in_novel: + existing_characters_str += f"\n" + existing_characters_str += str(character) + existing_characters_str += f"\n" + + characters_in_event_str = "" + for character in characters_in_event: + characters_in_event_str += f"\n" + characters_in_event_str += character.identifier_in_event + "\n" + characters_in_event_str += "Static features: " + character.static_features + "\n" + characters_in_event_str += f"\n" + + parser = PydanticOutputParser(pydantic_object=MergeCharactersToExistingCharactersInNovelResponse) + + messages = [ + SystemMessage( + content=system_prompt_template_merge_characters_to_existing_characters_in_novel.format( + format_instructions=parser.get_format_instructions(), + ), + ), + HumanMessage( + content=human_prompt_template_merge_characters_to_existing_characters_in_novel.format( + existing_characters_in_novel=existing_characters_str, + characters_in_event=characters_in_event_str, + ) + ) + ] + + chain = self.chat_model | parser + response: MergeCharactersToExistingCharactersInNovelResponse = chain.invoke(messages) + + for character in response.characters: + if character.index_in_novel == -1: + # new character, add to existing characters + new_character = CharacterInNovel( + index=len(existing_characters_in_novel), + identifier_in_novel=character.identifier_in_novel, + static_features=character.modified_features, + active_events={event_idx: characters_in_event[character.index_in_event].identifier_in_event}, + ) + existing_characters_in_novel.append(new_character) + else: + existing_characters_in_novel[character.index_in_novel].static_features = character.modified_features + existing_characters_in_novel[character.index_in_novel].active_events.update({event_idx: characters_in_event[character.index_in_event].identifier_in_event}) + + return existing_characters_in_novel + + + # # TODO: 如果是长篇小说,事件太多,很容易报错,出场的角色会分不清在哪个事件里,也很容易漏,需要想办法解决 + # @retry( + # stop=stop_after_attempt(3), + # after=lambda retry_state: logging.warning(f"Retrying due to {retry_state.outcome.exception()}"), + # ) + # def merge_characters_across_events_in_novel( + # self, + # events: List[Event], + # characters_in_event: List[List[CharacterInEvent]], + # ) -> List[CharacterInNovelWithoutStaticFeatures]: + # events_sequence_str = "" + # for event, characters in zip(events, characters_in_event): + # event_str = f"\n\n" + # event_str += "\n" + # event_str += event.description + "\n" + # event_str += "\n\n" + # event_str += "\n" + # for process in event.process_chain: + # event_str += process + "\n" + # event_str += "\n\n" + # event_str += "\n" + # for i, character in enumerate(characters): + # event_str += f"{character.identifier_in_event}\n" + # event_str += "\n\n" + # event_str += f"\n\n" + # events_sequence_str += event_str + + # parser = PydanticOutputParser(pydantic_object=MergeCharactersAcrossEventsInNovelResponse) + + # messages = [ + # SystemMessage( + # content=system_prompt_template_merge_characters_across_events.format( + # format_instructions=parser.get_format_instructions(), + # ), + # ), + # HumanMessage( + # content=human_prompt_template_merge_characters_across_events.format( + # events_sequence=events_sequence_str, + # ) + # ) + # ] + + # chain = self.chat_model | parser + # response: MergeCharactersAcrossEventsInNovelResponse = chain.invoke(messages) + # characters_in_novel = response.characters + + # # check the output is valid + # flags = [{c.identifier_in_event: False for c in characters} for characters in characters_in_event] + + # # check if all character identifiers can be found in the events + # for character in characters_in_novel: + # for event_idx, identifier_in_event in character.active_events.items(): + # if identifier_in_event not in [c.identifier_in_event for c in characters_in_event[event_idx]]: + # raise ValueError(f"Character {identifier_in_event} not found in event {event_idx}") + # else: + # flags[event_idx][identifier_in_event] = True + + # # check if all characters are included + # # for event_idx, flag in enumerate(flags): + # # for identifier_in_event, included in flag.items(): + # # if not included: + # # raise ValueError(f"Character {identifier_in_event} in event {event_idx} not included in the merged characters") + + # return characters_in_novel + + + + # async def extract_static_feature_for_character_in_novel( + # self, + # relevant_chunks: List[str], + # character: CharacterInNovelWithoutStaticFeatures, + # ) -> str: + # context_fragments_str = "" + # for i, chunk in enumerate(relevant_chunks): + # context_fragments_str += f"\n" + # context_fragments_str += chunk + "\n" + # context_fragments_str += f"\n" + + # parser = None # no need to parse the output, just return the text + + # messages = [ + # SystemMessage( + # content=system_prompt_template_extract_static_feature_for_character_in_novel, + # ), + # HumanMessage( + # content=human_prompt_template_extract_static_feature_for_character_in_novel.format( + # character_name=character.identifier_in_novel, + # context_fragments=context_fragments_str, + # ) + # ) + # ] + + # base_features = await self.chat_model.ainvoke(messages) + # return base_features.content diff --git a/agents/novel_compressor.py b/agents/novel_compressor.py new file mode 100644 index 0000000..e99fb75 --- /dev/null +++ b/agents/novel_compressor.py @@ -0,0 +1,171 @@ +import os +import logging +import asyncio +from typing import List, Tuple +from langchain_core.messages import HumanMessage, SystemMessage +from langchain.chat_models import init_chat_model +from langchain.text_splitter import RecursiveCharacterTextSplitter + + + +system_prompt_template_compress_novel_chunk = \ +""" +You are an expert text compression assistant specialized in literary content. Your goal is to condense novels or story excerpts while preserving core narrative elements, key details, character development, and plot coherence. + + +**TASK** +Compress the provided input text to reduce its length significantly, eliminating redundancies, overly descriptive passages, and minor details—but without losing essential story arcs, dialogue, or emotional impact. Aim for clarity and readability in the compressed output. + + +**INPUT** +A segment of a novel (possibly truncated due to context length constraints). It is enclosed within and tags. + + +**OUTPUT** +A compressed version of the input text, retaining the core narrative, critical events, and character interactions. + +**GUIDELINES** +1. Fidelity to the Plot: Absolutely preserve all major plot points, twists, revelations, and the sequence of key events. Do not omit crucial story elements. +2. Character Consistency: Maintain character actions, decisions, and development. Important dialogue that reveals plot or character can be condensed or paraphrased but its meaning must be kept intact. +3. Streamline Description: Reduce lengthy descriptions of settings, characters, or objects to their most essential and evocative elements. Capture the mood and critical details without the elaborate prose. +4. Condense Internal Monologue: Paraphrase characters' extended internal thoughts and reflections, focusing on the key realizations or decisions they lead to. +5. Simplify Language: Use more direct and concise language. Combine sentences, eliminate redundant adverbs and adjectives, and avoid repetitive phrasing. +6. Cohesion and Flow: Ensure the compressed text is smooth, readable, and maintains a logical narrative flow. It should not feel like a fragmented list of events. +7. Discard any non-narrative text (e.g., "Please follow my account!", "Background setting:...", personal opinions). +8. Produce a seamless paragraph (or paragraphs if necessary) without markers (e.g., "Chapter 1") or section breaks. +9. The language of output should be consistent with the original text. +""" + +human_prompt_template_compress_novel_chunk = \ +""" + +{novel_chunk} + +""" + + +system_prompt_template_aggregate = \ +""" +You are a professional text processing assistant specializing in the aggregation and refinement of segmented text chunks. Your expertise lies in seamlessly merging sequential text fragments while intelligently handling overlapping or duplicated content expressed in different ways. + +**TASK** +Aggregate the provided text chunks into a coherent and continuous short story. Carefully identify and resolve overlaps where the end of one chunk and the beginning of the next chunk contain semantically similar content but with different expressions. Remove redundant repetitions while preserving the original meaning, style, and flow of the text. Ensure all non-overlapping content remains unchanged and intact. + + +**INPUT** +A sequence of text chunks (ordered from first to last), where each chunk may have an overlapping segment with the next chunk. The overlapping segments might vary in wording but convey similar meaning. Each chunk is enclosed within and tags, where N is the chunk index starting from 0. + +**OUTPUT** +A single, consolidated text of the short story without unnatural repetitions or disruptions. The output should maintain the original narrative structure, tone, and details, with smooth transitions between originally adjacent chunks. + +**GUIDELINES** +1. Analyze the input chunks sequentially. For each adjacent pair (e.g., Chunk N and Chunk N+1), compare the end of Chunk N and the beginning of Chunk N+1 to detect overlapping content. +2. If the overlapping segments are semantically equivalent but phrased differently, merge them by retaining the most natural or contextually appropriate version (prioritize the version from the later chunk if both are equally valid, but avoid introducing inconsistency). +3. If the overlapping segments are not perfectly equivalent (e.g., one contains additional details), integrate the meaningful information without duplication, ensuring no loss of content. +4. Preserve all non-overlapping text exactly as it appears in the original chunks. Do not modify, paraphrase, or omit any unique content. +5. Ensure the merged text is fluent and coherent, without abrupt jumps or redundant phrases. +6. If no overlap is detected between two chunks, concatenate them directly without changes. +7. Do not invent new content or alter the original narrative beyond handling the overlaps. +8. The language of output should be consistent with the original text. +""" + +human_prompt_template_aggregate = \ +""" +{chunks} +""" + + + + +class NovelCompressor: + def __init__( + self, + api_key: str, + base_url: str, + chat_model: str, + chunk_size: int = 65536, + chunk_overlap: int = 8192, + ): + self.chat_model = init_chat_model( + model=chat_model, + api_key=api_key, + base_url=base_url, + model_provider="openai", + ) + + self.splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ) + + + def split( + self, + novel_text: str, + ): + novel_chunks = self.splitter.split_text(novel_text) + return novel_chunks + + + async def compress( + self, + index_chunk_pairs: List[Tuple[int, str]], + max_concurrent_tasks: int = 5, + ) -> str: + sem = asyncio.Semaphore(max_concurrent_tasks) + + tasks = [ + self.compress_single_novel_chunk(sem, index, novel_chunk) + for index, novel_chunk in index_chunk_pairs + ] + compressed_novel_chunks = await asyncio.gather(*tasks) + return compressed_novel_chunks + + + async def compress_single_novel_chunk( + self, + semaphore: asyncio.Semaphore, + index, + novel_chunk: str, + ) -> str: + async with semaphore: + logging.info(f"Compressing novel chunk {index}") + messages = [ + SystemMessage( + content=system_prompt_template_compress_novel_chunk + ), + HumanMessage( + content=human_prompt_template_compress_novel_chunk.format( + novel_chunk=novel_chunk + ) + ), + ] + response = await self.chat_model.ainvoke(messages) + compressed_novel_chunk = response.content + logging.info(f"Compressed novel chunk {index}") + return index, compressed_novel_chunk + + + def aggregate( + self, + compressed_novel_chunks: List[str], + ): + chunks_str = "\n".join([ + f"\n{chunk}\n" + for i, chunk in enumerate(compressed_novel_chunks) + ]) + + messages = [ + SystemMessage( + content=system_prompt_template_aggregate + ), + HumanMessage( + content=human_prompt_template_aggregate.format( + chunks=chunks_str + ) + ), + ] + response = self.chat_model.invoke(messages) + aggregated_novel = response.content + return aggregated_novel + diff --git a/agents/reference_image_selector.py b/agents/reference_image_selector.py new file mode 100644 index 0000000..b41aea1 --- /dev/null +++ b/agents/reference_image_selector.py @@ -0,0 +1,236 @@ +import logging +from typing import List, Tuple +from tenacity import retry, stop_after_attempt +from pydantic import BaseModel, Field +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models import init_chat_model +from utils.image import image_path_to_b64 + +from utils.retry import after_func + +system_prompt_template_select_reference_images_only_text = \ +""" +[Role] +You are a professional visual creation assistant skilled in multimodal image analysis and reasoning. + +[Task] +Your core task is to intelligently select the most suitable reference images from a provided set of reference image descriptions (including multiple character reference images and existing scene images from prior frames) based on the user's text description (describing the target frame), ensuring that the subsequently generated image meets the following key consistencies: +- Character Consistency: The appearance (e.g. gender, ethnicity, age, facial features, hairstyle, body shape), clothing, expression, posture, etc., of the generated character should highly match the reference image descriptions. +- Environmental Consistency: The scene of the generated image (e.g., background, lighting, atmosphere, layout) should remain coherent with the existing image descriptions from prior frames. +- Style Consistency: The visual style of the generated image (e.g., realistic, cartoon, film-like, color tone) should harmonize with the reference image descriptions. + +[Input] +You will receive a text description of the target frame, along with a sequence of reference image descriptions. +- The text description of the target frame is enclosed within and . +- The sequence of reference image descriptions is enclosed within and . Each description is prefixed with its index, starting from 0. + +Below is an example of the input format: + +[Camera 1] Shot from Alice's over-the-shoulder perspective. Alice is on the side closer to the camera, with only her shoulder appearing in the lower left corner of the frame. Bob is on the side farther from the camera, positioned slightly right of center in the frame. Bob's expression shifts from surprise to delight as he recognizes Alice. + + + +Image 0: A front-view portrait of Alice. +Image 1: A front-view portrait of Bob. +Image 2: [Camera 0] Medium shot of the supermarket aisle. Alice and Bob are shown in profile facing the right side of the frame. Bob is on the right side of the frame, and Alice is on the left side. Alice, looking down and pushing a shopping cart, follows closely behind Bob and accidentally bumps into his heel. +Image 3: [Camera 1] Shot from Alice's over-the-shoulder perspective. Alice is on the side closer to the camera, with only her shoulder appearing in the lower left corner of the frame. Bob is on the side farther from the camera, positioned slightly right of center in the frame. Bob quickly turns around, and his expression shifts from neutral to surprised. +Image 4: [Camera 2] Shot from Bob's over-the-shoulder perspective. Bob is on the side closer to the camera, with only his shoulder appearing in the lower right corner of the frame. Alice is on the side farther from the camera, positioned slightly left of center in the frame. Alice looks down, then up as she prepares to apologize. Upon realizing it's someone familiar, her expression shifts to one of surprise. + + + +[Output] +You need to select up to 8 of the most relevant reference images based on the user's description and put the corresponding indices in the ref_image_indices field of the output. At the same time, you should generate a text prompt that describes the image to be created, specifying which elements in the generated image should reference which image description (and which elements within it). + +{format_instructions} + + +[Guidelines] +- Ensure that the language of all output values (not include keys) matches that used in the frame description. +- The reference image descriptions may depict the same character from different angles, in different outfits, or in different scenes. Identify the description closest to the version described by the user +- Prioritize image descriptions with similar compositions, i.e., shots taken by the same camera. +- The images from prior frames are arranged in chronological order. Give higher priority to more recent images (those closer to the end of the sequence). +- Choose reference image descriptions that are as concise as possible and avoid including duplicate information. For example, if Image 3 depicts the facial features of Bob from the front, and Image 1 also depicts Bob's facial features from the front-view portrait, then Image 1 is redundant and should not be selected. +- When a new character appears in the frame description, prioritize selecting their portrait image description (if available) to ensure accurate depiction of their appearance. Pay attention to whether the character is facing the camera from the front, side, or back. Choose the most suitable view as the reference image for the character. +- For character portraits, you can only select at most one image from multiple views (front, side, back). Choose the most appropriate one based on the frame description. For example, when depicting a character from the side, choose the side view of the character. +- Select at most **8** optimal reference image descriptions. +""" + + +system_prompt_template_select_reference_images_multimodal = \ +""" +[Role] +You are a professional visual creation assistant skilled in multimodal image analysis and reasoning. + +[Task] +Your core task is to intelligently select the most suitable reference images from a provided reference image library (including multiple character reference images and existing scene images from prior frames) based on the user's text description (describing the target frame), ensuring that the subsequently generated image meets the following key consistencies: +- Character Consistency: The appearance (e.g. gender, ethnicity, age, facial features, hairstyle, body shape), clothing, expression, posture, etc., of the generated character should highly match the reference images. +- Environmental Consistency: The scene of the generated image (e.g., background, lighting, atmosphere, layout) should remain coherent with the existing images from prior frames. +- Style Consistency: The visual style of the generated image (e.g., realistic, cartoon, film-like, color tone) should harmonize with the reference images and existing images. + +[Input] +You will receive a text description of the target frame, along with a sequence of reference images. +- The text description of the target frame is enclosed within and . +- The sequence of reference images is enclosed within and . Each reference image is provided with a text description. The reference images are indexed starting from 0. + +Below is an example of the input format: + +[Camera 1] Shot from Alice's over-the-shoulder perspective. is on the side closer to the camera, with only her shoulder appearing in the lower left corner of the frame. is on the side farther from the camera, positioned slightly right of center in the frame. 's expression shifts from surprise to delight as he recognizes . + + + +Image 0: A front-view portrait of Alice. +[Image 0 here] +Image 1: A front-view portrait of Bob. +[Image 1 here] +Image 2: [Camera 0] Medium shot of the supermarket aisle. Alice and Bob are shown in profile facing the right side of the frame. Bob is on the right side of the frame, and Alice is on the left side. Alice, looking down and pushing a shopping cart, follows closely behind Bob and accidentally bumps into his heel. +[Image 2 here] +Image 3: [Camera 1] Shot from Alice's over-the-shoulder perspective. Alice is on the side closer to the camera, with only her shoulder appearing in the lower left corner of the frame. Bob is on the side farther from the camera, positioned slightly right of center in the frame. Bob is back to the camera. +[Image 3 here] +Image 4: [Camera 2] Shot from Bob's over-the-shoulder perspective. Bob is on the side closer to the camera, with only his shoulder appearing in the lower right corner of the frame. Alice is on the side farther from the camera, positioned slightly left of center in the frame. Alice looks down, then up as she prepares to apologize. Upon realizing it's someone familiar, her expression shifts to one of surprise. + + +[Output] +You need to select the most relevant reference images based on the user's description and put the corresponding indices in the `ref_image_indices` field of the output. At the same time, you should generate a text prompt that describes the image to be created, specifying which elements in the generated image should reference which image (and which elements within it). + +{format_instructions} + + +[Guidelines] +- Ensure that the language of all output values (not include keys) matches that used in the frame description. +- The reference image descriptions may depict the same character from different angles, in different outfits, or in different scenes. Identify the description closest to the version described by the user +- Prioritize image descriptions with similar compositions, i.e., shots taken by the same camera. +- The images from prior frames are arranged in chronological order. Give higher priority to more recent images (those closer to the end of the sequence). +- Choose reference image descriptions that are as concise as possible and avoid including duplicate information. For example, if Image 3 depicts the facial features of Bob from the front, and Image 1 also depicts Bob's facial features from the front-view portrait, then Image 1 is redundant and should not be selected. +- For character portraits, you can only select at most one image from multiple views (front, side, back). Choose the most appropriate one based on the frame description. For example, when depicting a character from the side, choose the side view of the character. +- Select at most **8** optimal reference image descriptions. +- The text guiding image editing should be as concise as possible. +""" + + +human_prompt_template_select_reference_images = \ +""" + +{frame_description} + +""" + + + + +class RefImageIndicesAndTextPrompt(BaseModel): + ref_image_indices: List[int] = Field( + description="Indices of reference images selected from the provided images. For example, [0, 2, 5] means selecting the first, third, and sixth images. The indices should be 0-based.", + examples=[ + [1, 3] + ] + ) + text_prompt: str = Field( + description="Text description to guide the image generation. You need to describe the image to be generated, specifying which elements in the generated image should reference which image (and which elements within it). For example, 'Create an image following the given description: \nThe man is standing in the landscape. The man should reference Image 0. The landscape should reference Image 1.' Here, the index of the reference image should refer to its position in the ref_image_indices list, not the sequence number in the provided image list. Refer to the reference image must be in the format of Image N. Do not use any other word except Image.", + examples=[ + "Create an image based on the following guidance: \n Make modifications based on Image 1: Bob's body turns to face the camera, while all other elements remain unchanged. Bob's appearance should refer to Image 0.", + "Create an image following the given description: \nThe man is standing in the landscape. The man should reference Image 0. The landscape should reference Image 1." + ] + ) + + + +class ReferenceImageSelector: + def __init__( + self, + chat_model, + ): + + self.chat_model = chat_model + + + @retry( + stop=stop_after_attempt(3), + after=after_func, + ) + async def select_reference_images_and_generate_prompt( + self, + available_image_path_and_text_pairs: List[Tuple[str, str]], + frame_description: str, + ): + filtered_image_path_and_text_pairs = available_image_path_and_text_pairs + + # 1. filter images using text-only model + if len(available_image_path_and_text_pairs) >= 8: + human_content = [] + for idx, (_, text) in enumerate(available_image_path_and_text_pairs): + human_content.append({ + "type": "text", + "text": f"Image {idx}: {text}" + }) + human_content.append({ + "type": "text", + "text": human_prompt_template_select_reference_images.format(frame_description=frame_description) + }) + parser = PydanticOutputParser(pydantic_object=RefImageIndicesAndTextPrompt) + + messages = [ + SystemMessage(content=system_prompt_template_select_reference_images_only_text.format(format_instructions=parser.get_format_instructions())), + HumanMessage(content=human_content) + ] + + chain = self.chat_model | parser + + try: + ref = await chain.ainvoke(messages) + filtered_image_path_and_text_pairs = select_pairs_by_indices(available_image_path_and_text_pairs, ref.ref_image_indices) + logging.info(f"Filtered image idx:{ref.ref_image_indices}") + + except Exception as e: + logging.error(f"Error get image prompt: \n{e}") + raise e + + # 2. filter images using multimodal model + human_content = [] + for idx, (image_path, text) in enumerate(filtered_image_path_and_text_pairs): + human_content.append({ + "type": "text", + "text": f"Image {idx}: {text}" + }) + human_content.append({ + "type": "image_url", + "image_url": {"url": image_path_to_b64(image_path)} + }) + human_content.append({ + "type": "text", + "text": human_prompt_template_select_reference_images.format(frame_description=frame_description) + }) + + parser = PydanticOutputParser(pydantic_object=RefImageIndicesAndTextPrompt) + + messages = [ + SystemMessage(content=system_prompt_template_select_reference_images_multimodal.format(format_instructions=parser.get_format_instructions())), + HumanMessage(content=human_content) + ] + + chain = self.chat_model | parser + + try: + response = await chain.ainvoke(messages) + reference_image_path_and_text_pairs = select_pairs_by_indices(filtered_image_path_and_text_pairs, response.ref_image_indices) + return { + "reference_image_path_and_text_pairs": reference_image_path_and_text_pairs, + "text_prompt": response.text_prompt, + } + + except Exception as e: + logging.error(f"Error get image prompt: \n{e}") + raise e + + + + +def select_pairs_by_indices(pairs, indices): + """Index into pairs with LLM-emitted indices, rejecting out-of-range values. + + Negative indices would silently select the wrong image via Python indexing. + """ + invalid = [i for i in indices if i < 0 or i >= len(pairs)] + if invalid: + raise ValueError(f"ref_image_indices out of range: {invalid} (have {len(pairs)} images)") + return [pairs[i] for i in indices] diff --git a/agents/scene_extractor.py b/agents/scene_extractor.py new file mode 100644 index 0000000..e40df94 --- /dev/null +++ b/agents/scene_extractor.py @@ -0,0 +1,109 @@ +from langchain_community.vectorstores import FAISS +from interfaces import Event, Scene +from langchain_core.messages import HumanMessage, SystemMessage +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from typing import List, Optional, Literal, Tuple, Dict +from langchain_core.output_parsers import PydanticOutputParser +from tenacity import retry, stop_after_attempt +import logging + +system_prompt_template_get_next_scene = \ +""" +You are an expert scriptwriter specializing in adapting literary works into structured screenplay scenes. Your task is to analyze event descriptions from novels and transform them into compelling screenplay scenes, leveraging relevant context while ignoring extraneous information. + +**TASK** +Generate the next scene for a screenplay adaptation based on the provided input. Each scene must include: +- Environment: slugline and detailed description +- Characters: List of characters appearing in the scene, with their static features (e.g., facial features, body shape), dynamic features (e.g., clothing, accessories), and visibility status +- Script: Character actions and dialogues in standard screenplay format + +**INPUT** +- Event Description: A clear, concise summary of the event to adapt. The event description is enclosed within and tags. +- Context Fragments: Multiple excerpts retrieved from the novel via RAG. These may contain irrelevant passages. Ignore any content not directly related to the event. The sequence of context fragments is enclosed within and tags. Each fragment in the sequence is enclosed within its own and tags, with N being the fragment number. +- Previous Scenes (if any): Already adapted scenes for context (may be empty). The sequence of previous scenes is enclosed within and tags. Each scene is enclosed within its own and tags, with N being the scene number. + +**OUTPUT** +{format_instructions} + +**GUIDELINES** +1. Extract scenes based on the provided context fragments. Strive to preserve the original meaning and dialogue without making arbitrary alterations. When adapting, ensure that every line of dialogue has a corresponding or derivative basis in the original text. +2. Focus on Relevance: Use only context fragments that directly align with the event description. Disregard any unrelated paragraphs. +3. Dialogues and Actions: Convert descriptive prose into actionable lines and dialogues. Invent minimal necessary dialogue if implied but not explicit in the context. +4. Conciseness: Keep descriptions brief and visual. Avoid prose-like explanations. +5. Format Consistency: Ensure industry-standard screenplay structure. +6. Implicit Inference: If context fragments lack exact details, infer logically from the event description or broader narrative context. +7. No Extraneous Content: Do not include scenes, characters, or dialogues unrelated to the core event. +8. The character must be an individual, not a group of individuals (such as a crowd of onlookers or a rescue team). +9. When the location or time changes, a new scene should be created. The total number of scenes should not more than 5!!! +10. The language of outputs in values should be same as the input. +""" + + +human_prompt_template_get_next_scene = \ +""" + +{event_description} + + + +{context_fragments} + + + +{previous_scenes} + +""" + + + + +class SceneExtractor: + def __init__( + self, + api_key, + base_url, + chat_model, + ): + self.chat_model = init_chat_model( + model=chat_model, + api_key=api_key, + base_url=base_url, + model_provider="openai", + ) + + @retry( + stop=stop_after_attempt(5), + after=lambda retry_state: logging.warning(f"Retrying SceneExtractor.get_next_scene due to error: {retry_state.outcome.exception()}"), + ) + async def get_next_scene( + self, + relevant_chunks: List[str], + event: Event, + previous_scenes: List[Scene] + ) -> Scene: + + context_fragments_str = "\n".join([f"\n{chunk}\n" for i, chunk in enumerate(relevant_chunks)]) + + previous_scenes_str = "\n".join([f"\n{scene}\n" for i, scene in enumerate(previous_scenes)]) + + parser = PydanticOutputParser(pydantic_object=Scene) + + messages = [ + SystemMessage( + content=system_prompt_template_get_next_scene.format( + format_instructions=parser.get_format_instructions(), + ), + ), + HumanMessage( + content=human_prompt_template_get_next_scene.format( + event_description=str(event), + context_fragments=context_fragments_str, + previous_scenes=previous_scenes_str, + ) + ) + ] + + chain = self.chat_model | parser + scene = await chain.ainvoke(messages) + return scene diff --git a/agents/screenwriter.py b/agents/screenwriter.py new file mode 100644 index 0000000..d1275dd --- /dev/null +++ b/agents/screenwriter.py @@ -0,0 +1,167 @@ +import logging +from typing import List, Optional +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from tenacity import retry, stop_after_attempt, wait_exponential + +from utils.retry import after_func + + + +system_prompt_template_develop_story = \ +""" +[Role] +You are a seasoned creative story generation expert. You possess the following core skills: +- Idea Expansion and Conceptualization: The ability to expand a vague idea, a one-line inspiration, or a concept into a fleshed-out, logically coherent story world. +- Story Structure Design: Mastery of classic narrative models like the three-act structure, the hero's journey, etc., enabling you to construct engaging story arcs with a beginning, middle, and end, tailored to the story's genre. +- Character Development: Expertise in creating three-dimensional characters with motivations, flaws, and growth arcs, and designing complex relationships between them. +- Scene Depiction and Pacing: The skill to vividly depict various settings and precisely control the narrative rhythm, allocating detail appropriately based on the required number of scenes. +- Audience Adaptation: The ability to adjust the language style, thematic depth, and content suitability based on the target audience (e.g., children, teenagers, adults). +- Screenplay-Oriented Thinking: When the story is intended for short film or movie adaptation, you can naturally incorporate visual elements (e.g., scene atmosphere, key actions, dialogue) into the narrative, making the story more cinematic and filmable. + +[Task] +Your core task is to generate a complete, engaging story that conforms to the specified requirements, based on the user's provided "Idea" and "Requirements." + +[Input] +The user will provide an idea within and tags and a user requirement within and tags. +- Idea: This is the core seed of the story. It could be a sentence, a concept, a setting, or a scene. For example, + - "A programmer discovers his shadow has a consciousness of its own.", + - "What if memories could be deleted and backed up like files?", + - "A locked-room murder mystery occurring on a space station." +- User Requirement (Optional): Optional constraints or guidelines the user may specify. For example, + - Target Audience: e.g., Children (7-12), Young Adults, Adults, All Ages. + - Story Type/Genre: e.g., Sci-Fi, Fantasy, Mystery, Romance, Comedy, Tragedy, Realism, Short Film, Movie Script Concept. + - Length: e.g., 5 key scenes, a tight story suitable for a 10-minute short film. + - Other: e.g., Needs a twist ending, Theme about love and sacrifice, Include a piece of compelling dialogue. + +[Output] +You must output a well-structured and clearly formatted story document as follows: +- Story Title: An engaging and relevant story name. +- Target Audience & Genre: Start by explicitly restating: "This story is targeted at [User-Specified Audience], in the [User-Specified Genre] genre." +- Story Outline/Summary: Provide a one-paragraph (100-200 words) summary of the entire story, covering the core plot, central conflict, and outcome. +Main Characters Introduction: Briefly introduce the core characters, including their names, key traits, and motivations. +- Full Story Narrative: + - If the number of scenes is unspecified, narrate the story naturally in paragraphs following the "Introduction - Development - Climax - Conclusion" structure. + - If a specific number of scenes (e.g., N scenes) is specified, clearly divide the story into N scenes, giving each a subheading (e.g., Scene One: Code at Midnight). The description for each scene should be relatively balanced, including atmosphere, character actions, and dialogue, all working together to advance the plot. +- The narrative should be vivid and detailed, matching the specified genre and target audience. +- The output should begin directly with the story, without any extra words. + +[Guidelines] +- The language of output should be same as the input. +- Idea-Centric: Keep the user's core idea as the foundation; do not deviate from its essence. If the user's idea is vague, you can use creativity to make reasonable expansions. +- Logical Consistency: Ensure that event progression and character actions within the story have logical motives and internal consistency, avoiding abrupt or contradictory plots. +- Show, Don't Tell: Reveal characters' personalities and emotions through their actions, dialogues, and details, rather than stating them flatly. For example, use "He clenched - his fist, nails digging deep into his palm" instead of "He was very angry." +- Originality & Compliance: Generate original content based on the user's idea, avoiding direct plagiarism of well-known existing works. The generated content must be positive, healthy, and comply with general content safety policies. +""" + +human_prompt_template_develop_story = \ +""" + +{idea} + + + +{user_requirement} + +""" + + + +system_prompt_template_write_script_based_on_story = \ +""" +[Role] +You are a professional AI script adaptation assistant skilled in adapting stories into scripts. You possess the following skills: +- Story Analysis Skills: Ability to deeply understand the story content, identify key plot points, character arcs, and themes. +- Scene Segmentation Skills: Ability to break down the story into logical scene units based on continuity of time and location. +- Script Writing Skills: Familiarity with script formats (e.g., for short films or movies), capable of crafting vivid dialogue, action descriptions, and stage directions. +- Adaptive Adjustment Skills: Ability to adjust the script's style, language, and content based on user requirements (e.g., target audience, story genre, number of scenes). +- Creative Enhancement Skills: Ability to appropriately add dramatic elements to enhance the script's appeal while remaining faithful to the original story. + +[Task] +Your task is to adapt the user's input story, along with optional requirements, into a script divided by scenes. The output should be a list of scripts, each representing a complete script for one scene. Each scene must be a continuous dramatic action unit occurring at the same time and location. + +[Input] +You will receive a story within and tags and a user requirement within and tags. +- Story: A complete or partial narrative text, which may contain one or more scenes. The story will provide plot, characters, dialogues, and background descriptions. +- User Requirement (Optional): A user requirement, which may be empty. The user requirement may include: + - Target audience (e.g., children, teenagers, adults). + - Script genre (e.g., micro-film, moive, short drama). + - Desired number of scenes (e.g., "divide into 3 scenes"). + - Other specific instructions (e.g., emphasize dialogue or action). + +[Output] +{format_instructions} + +[Guidelines] +- The language of output in values should be same as the input story. +- Scene Division Principles: Each scene must be based on the same time and location. Start a new scene when the time or location changes. If the user specifies the number of scenes, try to match the requirement. Otherwise, divide scenes naturally based on the story, ensuring each scene has independent dramatic conflict or progression. +- Script Formatting Standards: Use standard script formatting: Scene headings in full caps or bold, character names centered or capitalized, dialogue indented, and action descriptions in parentheses. +- Coherence and Fluidity: Ensure natural transitions between scenes and overall story flow. Avoid abrupt plot jumps. +- Visual Enhancement Principles: All descriptions must be "filmable". Use concrete actions instead of abstract emotions (e.g., "He turns away to avoid eye contact" instead of "He feels ashamed"). Decribe rich environmental details include lighting, props, weather, etc., to enhance the atmosphere. Visualize character performances such as express internal states through facial expressions, gestures, and movements (e.g., "She bites her lip, her hands trembling" to imply nervousness). +- Consistency: Ensure dialogue and actions align with the original story's intent, without deviating from the core plot. +""" + + +human_prompt_template_write_script_based_on_story = \ +""" + +{story} + + + +{user_requirement} + +""" + + +class Screenwriter: + def __init__( + self, + chat_model: str, + ): + self.chat_model = chat_model + + async def develop_story( + self, + idea: str, + user_requirement: Optional[str] = None, + ) -> str: + messages = [ + ("system", system_prompt_template_develop_story), + ("human", human_prompt_template_develop_story.format(idea=idea, user_requirement=user_requirement)), + ] + response = await self.chat_model.ainvoke(messages) + story = response.content + return story + + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=30), after=after_func) + async def write_script_based_on_story( + self, + story: str, + user_requirement: Optional[str] = None, + ) -> List[str]: + + + class WriteScriptBasedOnStoryResponse(BaseModel): + script: List[str] = Field( + ..., + description="The script based on the story. Each element is a scene " + ) + + parser = PydanticOutputParser(pydantic_object=WriteScriptBasedOnStoryResponse) + format_instructions = parser.get_format_instructions() + + messages = [ + ("system", system_prompt_template_write_script_based_on_story.format(format_instructions=format_instructions)), + ("human", human_prompt_template_write_script_based_on_story.format(story=story, user_requirement=user_requirement)), + ] + response = await self.chat_model.ainvoke(messages) + response = parser.parse(response.content) + script = response.script + return script + + + diff --git a/agents/script_enhancer.py b/agents/script_enhancer.py new file mode 100644 index 0000000..8dfbd8b --- /dev/null +++ b/agents/script_enhancer.py @@ -0,0 +1,122 @@ +import logging +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from tenacity import retry, stop_after_attempt + + +system_prompt_template_script_enhancer = \ +""" +[Role] +You are a senior screenplay polishing and continuity expert. + +[Task] +Enhance a planned narrative script by adding specific, concrete sensory details, tightening continuity, clarifying scene transitions, and keeping terminology consistent (character names, locations, objects). Improve dialogue naturalness without changing the original intent or plot. Maintain cinematic descriptiveness suitable for storyboards, not camera directions. + +[Input] +You will receive a planned script within and . + +[Output] +{format_instructions} + +[Guidelines] +1. Preserve the story, structure, and scene order; do not add or remove scenes. +2. Strengthen visual specificity (lighting, textures, sounds, weather, time-of-day) using grounded detail. +3. Ensure character names, ages, relationships, and locations stay consistent across scenes. +5. Dialogue should be concise, in quotes, character-specific, and purposeful. +6. Avoid camera jargon (e.g., cut to, close-up) and voiceover formatting. +7. No metaphors. +8. Repetition for Precision +Re‑state important objects/actors often (vehicle name, seat position, or character role) to remove ambiguity. Accuracy takes precedence over rhythm — redundancy is acceptable. +9. Character Features for Dialogue +For each character in the conversation, repeat the core voice description (e.g., male, early 50s, South African–North American accent) using the same prompt each time. +10. Preserve the original narration symbols if exists (eg. Narration: "Everything is looking good"). + +Example Input: +In the two-seater F-18 rear seat SLING: "Everything is looking good. All systems are green, Elon. We’re ready for takeoff." +In the two-seater F-18 front seat Elon Musk: "Understood, Sling. Let’s get this show on the road." +In the two‑seater F‑18 rear seat SLING: "Roger that. Strap in tight, boss. It’s gonna be a smooth ride." +In the two‑seater F‑18 front seat ELON MUSK: "Smooth is good. Let’s keep it that way." + +Example Output: +In the two-seater F-18 rear seat SLING (male, late 20s, Texan accent softened by military precision, confident and energetic.): "Everything is looking good. All systems are green, Elon. We’re ready for takeoff." +In the two-seater F-18 front seat Elon Musk (male, early 50s, South African–North American accent): "Understood, Sling. Let’s get this show on the road." +In the two‑seater F‑18 rear seat SLING (male, late 20s, Texan accent softened by military precision, confident and energetic.): "Roger that. Strap in tight, boss. It’s gonna be a smooth ride." +In the two‑seater F‑18 front seat ELON MUSK (male, early 50s, South African–North American accent): "Smooth is good. Let’s keep it that way." +10. Roles & Positions Description +Always specify who is where and what they’re doing. +Example Input: “In the cockpit front seat of the two‑seat F‑18, the pilot checks his controls.” +Example Output: “In the cockpit front seat of the two‑seat F‑18, Elon Musk checks his controls.” +Avoid shorthand (“the pilot”) unless you’ve already identified them in that exact position. + +Warnings +No camera directions. No metaphors. Do not change the plot. +""" + + +human_prompt_template_script_enhancer = \ +""" + +{planned_script} + +""" + + +class EnhancedScriptResponse(BaseModel): + enhanced_script: str = Field( + ..., + description="A refined script version with clearer continuity, stronger concrete detail, and improved dialogue while preserving the original story and scene order." + ) + + +class ScriptEnhancer: + def __init__( + self, + chat_model: str, + base_url: str, + api_key: str, + model_provider: str = "openai", + ): + self.chat_model = init_chat_model( + model=chat_model, + model_provider=model_provider, + base_url=base_url, + api_key=api_key, + ) + + @retry( + stop=stop_after_attempt(3), + after=lambda retry_state: logging.warning(f"Retrying enhance_script due to error: {retry_state.outcome.exception()}"), + ) + async def enhance_script( + self, + planned_script: str, + ) -> EnhancedScriptResponse: + """ + Enhance a planned script with more concrete detail and continuity polish. + """ + parser = PydanticOutputParser(pydantic_object=EnhancedScriptResponse) + prompt_template = ChatPromptTemplate.from_messages( + [ + ("system", system_prompt_template_script_enhancer), + ("human", human_prompt_template_script_enhancer), + ] + ) + chain = prompt_template | self.chat_model | parser + + try: + logging.info("Enhancing planned script...") + response: EnhancedScriptResponse = await chain.ainvoke( + { + "format_instructions": parser.get_format_instructions(), + "planned_script": planned_script, + } + ) + logging.info("Script enhancement completed.") + return response.enhanced_script + except Exception as e: + logging.error(f"Error enhancing script: \n{e}") + raise e + + diff --git a/agents/script_planner.py b/agents/script_planner.py new file mode 100644 index 0000000..c5771f8 --- /dev/null +++ b/agents/script_planner.py @@ -0,0 +1,433 @@ +import logging +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser +from langchain.chat_models import init_chat_model +from pydantic import BaseModel, Field +from typing import List, Optional, Literal +from tenacity import retry, stop_after_attempt, wait_exponential + +from utils.retry import after_func + + +narrative_script_prompt_template = \ +""" +You are a world-class creative writing and screenplay development expert with extensive experience in story structure, character development, and narrative pacing. + +**Task** +Your task is to transform a basic story idea into a comprehensive, engaging script with rich narrative detail, compelling character arcs, and cinematic storytelling elements. + +**Input** +You will receive a basic story idea or concept enclosed within and . + +Below is a simple example of the input: + + +A person discovers they can time travel but every time they change something, they lose a memory. + + +**Output** +{format_instructions} + +**Guidelines** +No metaphors allowed!!! (eg. A gust of wind rustled through it, a ghostly touch. ; an F1 car that looks less like a vehicle and more like a fighter jet stripped of its wings) + +1. **Story Structure**: Develop a clear three-act structure with proper setup, confrontation, and resolution. Include compelling plot points, rising action, climax, develop the content according to the plot timeline, maintain a clear main plotline, and maintain coherent narrative connections. Keep the plot moving forward. Avoid summarizing events and characters, and use dialogue between key characters appropriately. + +2. **Character Development**: Create well-rounded characters with clear motivations, flaws, and character arcs. Ensure protagonists have relatable goals and face meaningful obstacles. + +3. **Visual Storytelling**: Write with cinematic language that emphasizes visual elements, actions, and atmospheric details rather than exposition-heavy dialogue. + +4. **Emotional Depth**: Incorporate emotional beats, internal conflicts, and character relationships that resonate with audiences. + +5. **Pacing and Tension**: Build suspense and maintain engagement through proper scene transitions, conflict escalation, and strategic revelation of information. + +6. **Genre Consistency**: Maintain appropriate tone, style, and conventions for the story's genre while adding unique creative elements. + +7. **Dialogue Quality**: When you writing some dialogue, you should use the:" " symbols (eg. Peter says: "Everything is looking good. All systems are green, Elon. We’re ready for takeoff."). Do not use voiceover format. Create natural, character-specific dialogue that advances plot and reveals personality without being overly expository. + +8. **Thematic Elements**: Weave in meaningful themes and subtext that give the story depth and universal appeal. + +9. **Conflict and Stakes**: Establish clear external and internal conflicts with high stakes that matter to both characters and audience. + +10. **Satisfying Resolution**: Ensure all major plot threads are resolved and character arcs reach meaningful conclusions. + +11. **Each dialogue should not too short or too long, (eg."Everything is looking good. All systems are green, Elon. We’re ready for takeoff." ) + + +**Warnings** + +Don't write any camera movement in the script (eg. cut to), you should write the script by using storyboard description, not camera view. +No metaphors allowed!!! (eg. A gust of wind rustled through it, a ghostly touch. ; an F1 car that looks less like a vehicle and more like a fighter jet stripped of its wings) + + +**Examples of narrative scripts** + +The starry sky is vast, the Milky Way glittering. +On the beach, there's a fire, a portable dining table and chairs (three balloons tied to one corner, swaying in the wind), an SUV, and a camping tent. Next to the tent is an astronomical telescope. A man (Liu Peiqiang, 35, reserved) operates the telescope, while a little boy (Liu Qi, 4, Liu Peiqiang's son) observes under his father's guidance. +Liu Peiqiang (somewhat excitedly) Quick, quick, quick... Look, it's Jupiter... the largest planet in the solar system. +Adjusting the telescope's eyepiece's focus and position, Jupiter gradually comes into focus. Liu Qi: Dad, there's an eye on Jupiter. +Liu Peiqiang: That's not an eye, it's a massive storm on Jupiter's surface. Liu Qi: Why...? +Liu Peiqiang: (touching the boy's head, pointing to the balloons on the table) Jupiter is just a giant balloon, 90% hydrogen. Liu Qi: What is hydrogen? +An old man (Han Ziang, 59, Liu Peiqiang's father-in-law and Liu Qi's grandfather) walked out of the tent and stood silently beside Liu Peiqiang and his son. +Liu Peiqiang: Hydrogen... Hydrogen is the fuel for Dad's big rocket. The campfire flickered, and Han Ziang turned to look at Liu Peiqiang. Liu Qi: Why? Liu Peiqiang smiled and patted his son's head. +Liu Peiqiang (O.S.): When the day comes when you can see Jupiter without a telescope, Dad will be back. + + + +**Scriptwriting Guidelines End** + + +""" + +motion_script_prompt_template = \ +""" +You are a top-tier action and motion-sequence script designer with deep visual expertise in conveying speed, force, choreography, and technical precision. Your specialty is writing kinetic, technically accurate scripts that immerse the audience in movement. + +**Task** +Transform a basic idea into a motion-driven script that emphasizes precise action description, clear spatial orientation, and unambiguous, technically accurate details. + +**Input** +You will receive a basic idea enclosed within and . + +**Output** +{format_instructions} + +**Global Rules** +No metaphors allowed. Less conversation + +**Motion Style Guidelines** +1. Technical Explicitness: Prefer precise nouns and qualifiers over poetic language. Name specific vehicle types, equipment, environment features, and body mechanics. If vehicles are implied, specify make/class if reasonable. If combat, specify stance, guard, strike type, target, and contact result. +2. Kinetic Clarity: Make trajectories, vectors, speed/acceleration sensations, and force outcomes explicit. Describe distances and orientations when helpful (e.g., left/right, fore/aft). +3. Spatial Cohesion: Maintain a consistent mental map of positions. Keep continuity of who/what is where. When positions change, describe how and by what path. +4. Sequenced Action Beats: Write step-by-step beats that can be storyboarded. Each beat should be actionable and unambiguous. +5. Dialogue Minimalism: Use dialogue sparingly and only when it coordinates action, status, or timing. Use :"dialogue" quotes for spoken lines. +6. Keep the script length similar to the following examples. +7. If the user does not specify, only one character can appear at most. +8. Less character's actions close-ups, more exterior shots +9. Don't describe the character's physical state (e.g. jowls and the loose skin around its neck to press back). + +**Examples of motion & speed immersion fighter scripts** (should be accurate, technical, and explicit, Technical Explicitness: Consistently repeats “two seats F‑18” in each stage direction. Prioritizes precision in identifying the aircraft type and location (front seat / rear seat). Reads almost like a technical report or aviation manual, ensuring no ambiguity.) +The immense gray flight deck of a nuclear aircraft carrier cuts through a deep blue ocean. The horizon is a clean, sharp line. Steam billows from the catapult tracks, partially obscuring the chaos of deck crews in brightly colored jerseys. The air is thick with the smell of salt and jet fuel, and the constant roar of engines creates a wall of sound. + +An F-18, is positioned on the steam-powered catapult. Its twin engines blast waves of heat that distort the air behind it. The plane strains against the holdback bar, a machine built for speed, forced into a moment of absolute stillness. + +Epic cinematic style with dramatic wide shots, dynamic camera movements, rich color grading, and theatrical lighting reminiscent of major Hollywood productions. Camera gradually moves forward to pilot Elon Musk (50s, sharp eyes and unwavering focus) sits in the cockpit of a F-18. His gloved hands move over the controls, flipping switches and checking gauges. + +In the F-18 cockpit Elon Musk: "Understood, Sling. Let’s get this show on the road." + +In the F-18 cockpit Elon Musk's left hand push on the F-18 throttle, his right grips the control stick. + +A side view. The Shooter drops to one knee, pointing down the deck. The world seems to hold its breath. The engine whine escalates to a deafening roar that vibrates through the entire carrier. The F-18's twin vertical stabilizers shudder with contained power. + +First-person POV from inside the cockpit of F18. With a violent jolt, the catapult fires. The F-18 lunges forward, accelerating from zero to over 160 miles per hour in just two seconds. The deck becomes a blur of motion. Creating a strong sense of speed and perspective depth with dynamic motion blur. + +A side camera view. Then, with a surge of raw power from the afterburners igniting. The F-18 climbs, asserting its dominance over gravity. The landing gear retracts into the fuselage with a solid thud. Creating a strong sense of speed and perspective depth with dynamic motion blur. + +Elon Musk levels the F-18 wings, the sun glinting off his visor as he scans the empty sky ahead. + +The F-18, a sleek instrument of combat, roars to life as it pushes, slicing through the air with an elegant grace. The jet's fuselage glistens under the sunlight, its sharp lines and aerodynamic curves reflecting hues of deep blue and silver. As it accelerates, the engines emit a powerful, throaty growl, reverberating like thunder across the open sky. Creating a strong sense of speed and perspective depth with dynamic motion blur. + +**Examples of motion & speed immersion F1 racing scripts** +Epic cinematic style with dramatic wide shots, dynamic camera movements, rich color grading, and theatrical lighting reminiscent of major Hollywood productions. In the black and gold Formula One cockpit, Camera gradually moves forward to F1 driver Elon Musk (playing the driver, a man in his 40s, with a steely gaze and utter concentration) buckling his harness, his helmet visor which reflects the fluttering checkered flags and a blur of cheering spectators in the stands. He drives a sleek black and gold F1 car. + +The starting lights on the track go out, and First-person POV from inside the cockpit of a black and gold F1 car which starts and speeding through the Arena. You grip the wheel — full throttle. The engine roars, gear shifts snap. The blur of the cheering spectators in the stands flashes on your left. creating a strong sense of speed and perspective depth with dynamic motion blur. are engaged in a frenetic, no-holds-barred race. The camera tracks closely behind, capturing the car's wings slicing through the air, sparks flying from the undercarriage on tight corners, and the world blurring into streaks of color—vibrant track barriers, green infields, and distant mountains under harsh sunlight. + +The camera closely tracks the side with dynamic chasing shots., hugging the ground to capture Elon Musk's sleek black and gold F1 car slicing through the air, its APX tail wing flexing under the wind, sparks erupting from the chassis like fireworks as it powers through tight turns and begins overtaking rivals—dodging a pursuing Formula One car , nearly clipping in a heart-pounding near-miss. Cutting to another close-up on Elon Musk, his gloved hands gripping the F1 steering wheel tightly, while the background track barriers streak by in accelerated motion. Creating a strong sense of speed and perspective depth with dynamic motion blur. + +An aerial view for a wide chase perspective, showing Elon Musk's APX Formula One car boldly overtaking another rival in a daring maneuver, debris scattering across the asphalt as it pulls ahead, the pulsating to a crescendo amidst the intensified roar of engines, whistling wind, and the stronger surge of acceleration that makes the entire frame vibrate with raw power. Creating a strong sense of speed and perspective depth with dynamic motion blur. are engaged in a frenetic, no-holds-barred race. + +A front-mounted chase shot follows, emphasizing the APX tail wing's metallic sheen as the black and gold F1 car banks into a hairpin turn, other Formula One rivals closing in from both sides in a tense three-way battle, the movement acceleration pushing the limits as Elon Musk's black and gold F1 car breaks free, leaving F1 competitors in a cloud of dust. + +The camera jolts into a raw handheld shot as Elon Musk’s APX black and gold F1 car rockets down a blistering straightaway, creating a strong sense of speed and perspective depth with dynamic motion blur, are engaged in a frenetic, no-holds-barred race. Rivals' red-white Formula one car closing in tight on both flanks. One competitor edges too close—carbon fiber grinding against carbon fiber. Sparks erupt in a spray of gold as Elon Musk wrenches the wheel, but the rival's red-white Formula one car fishtails, spinning out of control before slamming violently into the barrier. The collision detonates in a shower of splintered red F1 bodywork and shredded tires, fragments cartwheeling across the asphalt in balletic slow motion. + +Wide aerial shots capture the chaos as smoke and dust mushroom upward, the track swallowed in a haze of flame-orange light. Then—an explosive cut back to full speed—Elon Musk’s sleek black and gold F1 APX car bursts through the choking smoke cloud, unbroken, streaking down the straight. Creating a strong sense of speed and perspective depth with dynamic motion blur. are engaged in a frenetic, no-holds-barred race. + +Another extreme close-up zooms in on F1 driver Elon Musk's visor, the lens focus pronouncing the reflection of the track rushing by, capturing the intensity of his focus amid the chaos. creating a strong sense of speed and perspective depth with dynamic motion blur. + +The sequence escalates with a low-angle chase shot from behind, creating a strong sense of speed and perspective depth with dynamic motion blur. Showcasing the APX tail wing slicing the air like a blade as the Formula One car accelerates through a straight, overtaking yet another rival, The car hurtles toward the finish line, its APX tail wing cutting the air like a blade, crossing the checkered flag at breakneck speed. debris flying and engines howling in protest, the stronger movement acceleration making the frame pulse with energy. + +**Warnings** +- Do not use metaphors. + +""" + + + + + + + + + + + + +montage_script_prompt_template = \ +""" +You are a top-tier montage script designer with deep expertise in compressing time, juxtaposing images, and shaping emotional arcs through shot selection and rhythm. Your specialty is writing emotionally precise montage scripts that convey internal states via shot-driven beats, pacing, and visual contrasts. + +Task +Transform a basic idea into an emotion-driven montage script that emphasizes internal experience through visual sequencing, clear emotional expression per shot/beat, and unambiguous psychological details. + +Input +You will receive a basic idea enclosed within and . + +Output +{format_instructions} + +**Global Rules** +No metaphors allowed. +Keep dialogue minimal. +Use pure paragraph. +Convey meaning primarily through shot progression, rhythm, and visual juxtaposition. +Montage Style Guidelines +Use plain sentence/paragraph +For each secene, you should write multiple shots to enhance montage effect. +Total no less than 500 words, each paragraph no more than 50 words. +Escalation or Resolution: Build an emotional arc across beats. Show explicit changes in emotional state and the cause for each change. +Sound Design Minimalism: Use sparse, precise notes for sound/music that influence emotion (tempo rise, percussive cuts, breath presence). Avoid lyrical description. +Dialogue Minimalism: Include dialogue only if it marks a clear emotional shift. Use :"dialogue" quotes. +Visual Clarity Over Action: Limit complex external action. Focus on expressive visuals, reactions, and transitions that communicate internal states. +No extraneous physical traits. Only describe details that influence or reveal emotion. +**Warnings** +Do not use metaphors. +Avoid poetic language; prefer precise, observable details. + +**Examples of scripts** +Morning light across a small practice room. A girl (Lisa) around seven lifts a violin from its case. Bow slips on the first note. + + +She (Lisa) winces, then tries again. Shoulders ease. Relief. Quiet room, a single chair creak. + + +She (Lisa) rests her cheek on the chinrest. The string hum stabilizes. + + +A small smile shows on Lisa. + + +Front hall. School shoes near a folded music stand. + + +She (Lisa) struggles with the latch. The stand clicks open. Light metal tap on tile. + + +Afternoon window. She (Lisa) traces notes with a finger. Her mother taps a rhythm on the table. + + +She (Lisa) frowns, then raises her elbow. Concentration holds. The bow settles. Shared stillness. Page flip, steady breath. + + +Bathroom. She (Lisa) wipes rosin dust off the instrument, coughing once. + + +Bedroom floor. Sheet music spread. She (Lisa) circles three notes with a red pencil. + + +She (Lisa) plays them alone, slow, then again faster. Frustration dips, control returns. Pencil tap stops. + + +Kitchen doorway. A metronome ticks beside a bowl of fruit. She (Lisa) dials it down two clicks. Shoulders drop. She follows the pulse, bow hand steadier with each measure. + + +Living room. A TV murmurs. She (Lisa) crosses, lowers the volume, returns to her stand. Boundary set without words. The room holds for practice. + + +Front steps. Case open to the sun. A neighbor waves. She (Lisa) shields the strings with her palm, smiles, and closes the lid. Protection learned. + + +Music store aisle. Shoulder rests in a row. She (Lisa) tries one that squeaks, then another that fits. Jaw unclenches. She nods, decision made. + + +Rain on the window. She (Lisa) misses a shift three times. Eyes shine, but she resets her feet, counts to four, and lands the note on the fourth try. Relief, not triumph. Bow lifts, still. + + +Mirror practice. Thin tape marks on the fingerboard. She (Lisa) glances once, places a finger true, then plays without looking. Confidence grows around the guide. + + +School hallway before recital. Cold hands under a dryer. She (Lisa) shakes out wrists. Fear thins to focus. She walks toward the stage door, steps even. + + +Curtain edge. Small tremor at the frog. She (Lisa) loosens grip, breathes, and steps into light. + + +Two clean phrases. One fuzzy entrance. She (Lisa) holds tempo, corrects on the next measure. Recovery without apology. + + +Exit corridor. Water bottle cap clicks. She (Lisa) writes in a pocket notebook: “Entrance softer, elbow high.” Emotion measured by action. + + +Saturday morning. An online tutorial freezes mid-vibrato. She (Lisa) mimics the motion without sound. Adds bow. Wobble uneven. She smiles anyway. Incremental progress accepted. + + +Park bench. Practice mute on the bridge. Joggers pass without looking. She (Lisa) finishes a scale, closes her eyes a moment, then starts the etude. Privacy inside noise. + + +Bedroom desk. A planner open. She (Lisa) blocks out “scales + shifts” for fifteen minutes daily. A small star beside Sunday. Plan replaces hope. + + +Evening soreness. A red mark under her jaw. She (Lisa) folds a soft cloth over the rest, tries again. Mark fades. Comfort adjusted, practice continues. + + +String snap. Sharp, quick. She (Lisa) flinches, then opens a spare packet, threads, winds, tunes slow. Disruption handled. Bow returns to the string. + + +Phone buzz. A friend’s invitation lights the screen. She (Lisa) looks once, turns it face down, and plays the piece end to end. Reward after task. + + +Audition day. Waiting chairs in a line. She (Lisa) air-bows the first phrase, eyes closed. Shoulders stay low. Name called. She stands smoothly. + + +Small studio. Two judges, still faces. She (Lisa) tunes, begins. First note centered, breath even. A slip in the middle; tempo holds. The last note rings. + + +Street outside. She (Lisa) exhales into cool air, checks her watch, and walks home. No jump, no slump. Next step implied. + + +Kitchen table. Acceptance email on a tablet. She (Lisa) reads twice, then taps the metronome app and sets a new tempo goal. Celebration nested inside routine. + + +Summer afternoon. Open window, distant mower. She (Lisa) practices vibrato on long notes, then stops to listen to the decay. Ear sharpens. + + +Library corner. She (Lisa) copies fingerings in neat pencil on a fresh sheet. The messy draft slides into recycling. Order replaces clutter. + + +Community center stage. A quartet rehearsal. She (Lisa) watches the leader’s breath, lifts with it, and enters together. Listening added to playing. + + +Night lamp. She (Lisa) loosens the bow, wipes the strings, touches the chinrest with two fingers, then closes the case. Habit completes the day. Quiet returns. +""" + + + + + +human_prompt_template_script_planner = \ +""" + +{basic_idea} + +""" + + +class IntentRouterResponse(BaseModel): + intent: Literal["narrative", "motion", "montage"] = Field( + ..., description="Routing decision: 'narrative' for characters multi-conversation focus, 'motion' for action/kinetic focus, 'montage' for emotional montage focus" + ) + rationale: Optional[str] = Field( + default=None, description="Brief reason for the classification" + ) + + +class PlannedScriptResponse(BaseModel): + planned_script: str = Field( + ..., + description="The full planned script with rich narrative detail, character development, dialogue, and cinematic descriptions. Should be significantly more detailed and engaging than the original basic idea." + ) + + + +class ScriptPlanner: + def __init__( + self, + chat_model: str, + base_url: str, + api_key: str, + model_provider: str = "openai", + ): + self.chat_model = init_chat_model( + model=chat_model, + model_provider=model_provider, + base_url=base_url, + api_key=api_key, + ) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=30), after=after_func) + def plan_script( + self, + basic_idea: str, + ) -> PlannedScriptResponse: + """ + Plan a comprehensive script from a basic idea. + + Args: + basic_idea: A simple story concept or idea to be expanded + + Returns: + PlannedScriptResponse: A comprehensive script with structure, characters, and narrative detail + """ + # 1) Route intent to select the appropriate template + router_parser = PydanticOutputParser(pydantic_object=IntentRouterResponse) + router_prompt_template = ChatPromptTemplate.from_messages( + [ + ( + 'system', + """ + You are an intent router for script planning. Classify the user's basic idea into one of following intents: + + - narrative: The idea centers on character, plot, themes, dialogue, or broad storytelling beats. + - motion: The idea centers on action, speed, vehicles, combat, choreography, sports, or any kinetic sequence where precise, technical motion description is primary. + - montage: The idea centers on a series of shots that convey an emotional arc through imagery, pacing, and juxtaposition. + + Respond using the required JSON format only + {format_instructions} + """ + ), + ('human', human_prompt_template_script_planner), + ] + ) + router_chain = router_prompt_template | self.chat_model | router_parser + + routing = router_chain.invoke( + { + "format_instructions": router_parser.get_format_instructions(), + "basic_idea": basic_idea, + } + ) + chosen_intent = routing.intent if isinstance(routing, IntentRouterResponse) else "narrative" + logging.info(f"[ScriptPlanner] Intent routed to: {chosen_intent}") + + # 2) Build the planning chain with the selected template + planning_parser = PydanticOutputParser(pydantic_object=PlannedScriptResponse) + + # Template selection with graceful fallbacks + def get_system_template(intent: str) -> str: + try: + if intent == "narrative": + return narrative_script_prompt_template + if intent == "motion": + return motion_script_prompt_template + if intent == "montage": + return montage_script_prompt_template + except NameError: + # Fallbacks if specific templates not defined in scope + pass + # Default fallback + return narrative_script_prompt_template + + system_template = get_system_template(chosen_intent) + + planning_prompt_template = ChatPromptTemplate.from_messages( + [ + ('system', system_template), + ('human', human_prompt_template_script_planner), + ] + ) + planning_chain = planning_prompt_template | self.chat_model | planning_parser + + try: + logging.info(f"Planning script from basic idea: {basic_idea[:100]}...") + response = planning_chain.invoke( + { + "format_instructions": planning_parser.get_format_instructions(), + "basic_idea": basic_idea, + } + ) + logging.info("Script planning completed.") + return response + except Exception as e: + logging.error(f"Error planning script: \n{e}") + raise e + + diff --git a/agents/storyboard_artist.py b/agents/storyboard_artist.py new file mode 100644 index 0000000..b5ed8e1 --- /dev/null +++ b/agents/storyboard_artist.py @@ -0,0 +1,275 @@ +from typing import List, Optional, Literal +import asyncio +from pydantic import BaseModel, Field +from tenacity import retry, stop_after_attempt + +from langchain.chat_models.base import BaseChatModel +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import PydanticOutputParser +from interfaces import CharacterInScene, ShotDescription, ShotBriefDescription + +from utils.retry import after_func + + + +system_prompt_template_design_storyboard = \ +""" +[Role] +You are a professional storyboard artist with the following core skills: +- Script Analysis: Ability to quickly interpret a script's text, identifying the setting, character actions, dialogue, emotions, and narrative pacing. +- Visualization: Expertise in translating written descriptions into visual frames, including composition, lighting, and spatial arrangement. +- Storyboarding: Proficiency in cinematic language, such as shot types (e.g., close-up, medium shot, wide shot), camera angles (e.g., high angle, eye-level), camera movements (e.g., zoom, pan), and transitions. +- Narrative Continuity: Ability to ensure the storyboard sequence is logically smooth, highlights key plot points, and maintains emotional consistency. +- Technical Knowledge: Understanding of basic storyboard formats and industry standards, such as using numbered shots and concise descriptions. + +[Task] +Your task is to design a complete storyboard based on a user-provided script (which contains only one scene). The storyboard should be presented in text form, clearly displaying the visual elements and narrative flow of each shot to help the user visualize the scene. + +[Input] +The user will provide the following input. +- Script:A complete scene script containing dialogue, action descriptions, and scene settings. The script focuses on only one scene; there is no need to handle multiple scene transitions. The script input is enclosed within . +- Characters List: A list describing basic information for each character, such as name, personality traits, appearance (if relevant). The character list is enclosed within and . +- User requirement: The user requirement (optional) is enclosed within and , which may include: + - Target audience (e.g., children, teenagers, adults). + - Storyboard style (e.g., realistic, cartoon, abstract). + - Desired number of shots (e.g., "not more than 10 shots"). + - Other specific instructions (e.g., emphasize the characters' actions). + +[Output] +{format_instructions} + +[Guidelines] +- Ensure all output values (except keys) match the language used in the script. +- Each shot must have a clear narrative purpose—such as establishing the setting, showing character relationships, or highlighting reactions. +- Use cinematic language deliberately: close-ups for emotion, wide shots for context, and varied angles to direct audience attention. +- When designing a new shot, first consider whether it can be filmed using an existing camera position. Introduce a new one only if the shot size, angle, and focus differ significantly. If the camera undergoes significant movement, it cannot be used thereafter. +- Keep character names in visual descriptions and speaker fields consistent with the character list. In visual descriptions, enclose names in angle brackets (e.g., ), but not in dialogue or speaker fields. +- When describing visual elements, it is necessary to indicate the position of the element within the frame. For example, Character A is on the left side of the frame, facing toward the right, with a table in front of him. The table is positioned slightly to the left of the center of the frame. Ensure that invisible elements are not included. For instance, do not describe someone behind a closed door if they cannot be seen. +- Avoid unsafe content (violence, discrimination, etc.) in visual descriptions. Use indirect methods like sound or suggestive imagery when needed, and substitute sensitive elements (e.g., ketchup for blood). +- Assign at most one dialogue line per character per shot. Each line of dialogue should correspond to a shot. +- Each shot requires an independent description without reference to each other. +- When the shot focuses on a character, describe which specific body part the focus is on. +- When describing a character, it is necessary to indicate the direction they are facing. +""" + + +human_prompt_template_design_storyboard = \ +""" + + + +{characters_str} + + + +{user_requirement_str} + +""" + + + +system_prompt_template_decompose_visual_description = \ +""" +[Role] +You are a professional visual text analyst, proficient in cinematic language and shot narration. Your expertise lies in deconstructing a comprehensive shot description accurately into three core components: the static first frame, the static last frame, and the dynamic motion that connects them. + +[Task] +Your task is to dissect and rewrite a user-provided visual text description of a shot strictly and insightfully into three distinct parts: +- First Frame Description: Describe the static image at the very beginning of the shot. Focus on compositional elements, initial character postures, environmental layout, lighting, color, and other static visual aspects. +- Last Frame Description: Describe the static image at the very end of the shot. Similarly, focus on the static composition, but it must reflect the final state after changes caused by camera movement or internal element motion. +- Motion Description: Describe all movements that occur between the first frame and the last frame. This includes camera movement (e.g., static, push-in, pull-out, pan, track, follow, tilt, etc.) and movement of elements within the shot (e.g., character movement, object displacement, changes in lighting, etc.). This is the most dynamic part of the entire description. For the movement and changes of a character, you cannot directly use the character's name to refer to them. Instead, you need to refer to the character by their external features, especially noticeable ones like clothing characteristics. + +[Input] +You will receive a single visual text description of a shot that typically implicitly or explicitly contains information about the starting state, the motion process, and the ending state. +Additionally, you will receive a sequence of potential characters, each containing an identifier and a feature. +- The description is enclosed within and . +- The character list is enclosed within and . + + +[Output] +{format_instructions} + +[Guidelines] +- Ensure all output values (except keys) match the language used in the script. +- Ensure the first and last frame descriptions are pure "snapshots," containing no ongoing actions (e.g., "He is about to stand up" is unacceptable; it should be "He is sitting on the chair, leaning slightly forward"). +- In the motion description, you must clearly distinguish between camera movement and on-screen movement. Use professional cinematic terminology (e.g., dolly shot, pan, zoom, etc.) as precisely as possible to describe camera movement. +- In the motion description, you cannot directly use character names to refer to characters; instead, you should use the characters' visible characteristics to refer to them. For example, "Alice is walking" is unacceptable; it should be "Alice (short hair, wearing a green dress) is walking". +- The last frame description must be logically consistent with the first frame description and the motion description. All actions described in the motion section should be reflected in the static image of the last frame. +- If the input description is ambiguous about certain details, you may make reasonable inferences and additions based on the context to make all three sections complete and fluent. However, core elements must strictly adhere to the input text. +- Use accurate, concise, and professional descriptive language. Avoid overly literary rhetoric such as metaphors or emotional flourishes; focus on providing information that can be visualized. +- Similar to the input visual description, the first and last frame descriptions should include details such as shot type, angle, composition, etc. +- Below are the three types of variation within a shot (not between two shots): +(1) 'large' cases typically involve the exaggerated transition shots which means a significant change in the composition and focus, such as smoothly changing from a wide shot to a close-up. It is usually accompanied by significant camera movement (e.g., drone perspective shots across the city). +(2) 'medium' cases often involve the introduction of new characters and a character turns from the back to face the front (facing the camera). +(3) 'small' cases usually involve minor changes, such as expression changes, movement and pose changes of existing characters(e.g., walking, sitting down, standing up), moderate camera movements(e.g., pan, tilt, track). +- When describing a character, it is necessary to indicate the direction they are facing. +- The first shot must establish the overall scene environment, using the widest possible shot. +- Use as few camera positions as possible. +""" + + +human_prompt_template_decompose_visual_description = \ +""" + +{visual_desc} + + + +{characters_str} + +""" + + +class VisDescDecompositionResponse(BaseModel): + ff_desc: str = Field( + description="A detailed description of the first frame of the shot, capturing the initial visual elements and composition.", + # examples=[ + # "Medium shot of a supermarket aisle at eye level. Bob(a tall man wearing a blue shirt and jeans) is positioned on the right side of the frame, captured in profile and facing right, while Alice(a young woman with short hair, wearing a green dress) is on the left, shown pushing a shopping cart with her gaze lowered toward the ground. They are arranged in a front-to-back spatial relationship. Shelves line both sides of the frame, and cool-toned fluorescent lighting from above washes over the scene. The vibrant colors of product packaging contrast with the metallic gray of the shopping cart, all contained within a stable, horizontally balanced composition.", + # "Extreme long shot. Aerial view from hundreds of meters above the ground. The boundless golden desert resembles undulating frozen waves, occupying the vast majority of the frame. At the very center of the image, a tiny, solitary explorer appears only as a faint dark speck, dragging a long, lonely trail of footprints behind him, stretching all the way to the edge of the frame.", + # "Medium shot at eye level angle. Designer A(with a beard, wearing a white suit) leans forward passionately, speaking emphatically. Product Manager B(with a beard, wearing a white T-shirt) sits with crossed arms, looking skeptical. Between them, Development Engineer C(brown hair, wearing a blue T-shirt) appears anxious, glancing between the two. Project Manager D(curly hair, wearing a red T-shirt) prepares to mediate, focusing on a whiteboard. Bright overhead lighting highlights their expressions, with a blurred whiteboard and glass wall in the background.", + # "A low-angle close-up shot captures the figure from below, framing him from the chest up. His face appears resolute and commanding, his eyes piercing as he speaks passionately. Flecks of saliva are visible, emphasizing his intensity. The overcast sky breaks with occasional light, casting him as a heroic, almost monumental figure against the gloom.", + # "An extremely close-up of an old, motionless pocket watch. Soft light highlights scratches on its brass case and the enamel dial with Roman numerals. The second hand remains fixed at 'VIII', casting a sharp shadow. A wrinkled finger gently touches the glass surface, evoking a tangible sense of stillness and time.", + # "An over-the-shoulder shot at eye level, positioned behind Character A(red hair, wearing a white T-shirt). The foreground, including A's shoulder and head, is softly blurred, directing focus onto Character B(with a beard, wearing a white T-shirt)'s face. B's subtle reactions—shifting from surprise to confusion, then to a glimmer of understanding—are clearly visible. The café background is gently blurred with warm lighting.", + # ] + ) + ff_vis_char_idxs: List[int] = Field( + description="A list of indices of characters that are visible in the first frame of the shot, corresponding to the character list provided in the input.", + examples=[[0], [1], [0, 1], []] + ) + lf_desc: str = Field( + description="A detailed description of the last frame of the shot, capturing the concluding visual elements and composition.", + ) + lf_vis_char_idxs: List[int] = Field( + description="A list of indices of characters that are visible in the last frame of the shot, corresponding to the character list provided in the input.", + examples=[[0], [1], [0, 1], []] + ) + motion_desc: str = Field( + description="The motion description of the shot. Describe the dynamic visual changes within the shot (camera movement and the movement of elements within the frame)", + examples=[ + "Static camera. Alice (short hair, wearing a green dress) is walking towards the camera.", + "Dolly in from meidum shot to close-up. Bob (with a beard, wearing a white T-shirt) smiles to the camera.", + ] + ) + variation_type: Literal["large", "medium", "small"] = Field( + description="Indicates the degree of change between the first frame and the last frame.", + ) + variation_reason: str = Field( + description="The reason for the variation type of the shot.", + examples=[ + "This is a smooth transition shot from the sky to the ground. The content of the shot has changed significantly, so the variation type is large.", + "Compared to the first frame, a new character appears in the last frame, and there are no significant changes in the composition. So the variation type is medium.", + "Compared to the first frame, there are only minor changes in the composition. So the variation type is small.", + "This shot only shows Alice speaking and the changes in her facial expressions, thus the variation type is small.", + ], + ) + + + +class StoryboardArtist: + def __init__( + self, + chat_model: BaseChatModel, + ): + self.chat_model = chat_model + + + @retry(stop=stop_after_attempt(3), after=after_func) + async def design_storyboard( + self, + script: str, + characters: List[CharacterInScene], + user_requirement: Optional[str] = None, + retry_timeout: int = 150, + ) -> List[ShotBriefDescription]: + + class StoryboardResponse(BaseModel): + storyboard: List[ShotBriefDescription] = Field( + description="A complete storyboard of the scene, including the visual and audio description of each shot.", + ) + + script_str = script.strip() + characters_str = "\n".join([f"Character {index}: {char}" for index, char in enumerate(characters)]) + user_requirement_str = user_requirement.strip() if user_requirement else "" + + parser = PydanticOutputParser(pydantic_object=StoryboardResponse) + messages = [ + ('system', system_prompt_template_design_storyboard.format(format_instructions=parser.get_format_instructions())), + ('human', human_prompt_template_design_storyboard.format(script_str=script_str, characters_str=characters_str, user_requirement_str=user_requirement_str)), + ] + chain = self.chat_model | parser + response: StoryboardResponse = await asyncio.wait_for( + chain.ainvoke(messages), + timeout=retry_timeout, + ) + storyboard = response.storyboard + + return storyboard + + + + + @retry(stop=stop_after_attempt(3), after=after_func) + async def decompose_visual_description( + self, + shot_brief_desc: ShotBriefDescription, + characters: List[CharacterInScene], + retry_timeout: int = 150, + ) -> ShotDescription: + parser = PydanticOutputParser(pydantic_object=VisDescDecompositionResponse) + prompt_template = ChatPromptTemplate.from_messages( + [ + ('system', system_prompt_template_decompose_visual_description), + ('human', human_prompt_template_decompose_visual_description), + ] + ) + chain = prompt_template | self.chat_model | parser + + visual_desc = shot_brief_desc.visual_desc.strip() + + characters_str = "\n".join([f"{char.identifier_in_scene}: (static) {char.static_features}; (dynamic) {char.dynamic_features}" for char in characters]) + + decomposition: VisDescDecompositionResponse = await asyncio.wait_for( + chain.ainvoke( + input={ + "format_instructions": parser.get_format_instructions(), + "visual_desc": visual_desc, + "characters_str": characters_str, + }, + ), + timeout=retry_timeout, + ) + + validate_char_idxs(decomposition.ff_vis_char_idxs, len(characters), "ff_vis_char_idxs") + validate_char_idxs(decomposition.lf_vis_char_idxs, len(characters), "lf_vis_char_idxs") + + return ShotDescription( + idx=shot_brief_desc.idx, + is_last=shot_brief_desc.is_last, + cam_idx=shot_brief_desc.cam_idx, + visual_desc=shot_brief_desc.visual_desc, + variation_type=decomposition.variation_type, + variation_reason=decomposition.variation_reason, + ff_desc=decomposition.ff_desc, + ff_vis_char_idxs=decomposition.ff_vis_char_idxs, + lf_desc=decomposition.lf_desc, + lf_vis_char_idxs=decomposition.lf_vis_char_idxs, + motion_desc=decomposition.motion_desc, + audio_desc=shot_brief_desc.audio_desc, + ) + + +def validate_char_idxs(idxs, num_characters, field_name): + """Reject LLM-emitted character indices outside [0, num_characters). + + Negative values would silently select the wrong character via Python + indexing; out-of-range values would crash deep inside the render gather. + Raising here lets the @retry on decompose_visual_description re-ask. + """ + invalid = [idx for idx in idxs if idx < 0 or idx >= num_characters] + if invalid: + raise ValueError( + f"{field_name} contains invalid character indices {invalid}; " + f"valid range is 0..{num_characters - 1}" + ) diff --git a/assets/vimax.png b/assets/vimax.png new file mode 100644 index 0000000..598bab2 Binary files /dev/null and b/assets/vimax.png differ diff --git a/configs/agent.example.yaml b/configs/agent.example.yaml new file mode 100644 index 0000000..8f5ea4f --- /dev/null +++ b/configs/agent.example.yaml @@ -0,0 +1,31 @@ +# ViMax agent runtime local configuration template. +# Keep real keys out of commits. Prefer environment variables for shared or CI usage. + +llm: + model_provider: openai + model: + base_url: + api_key: '' + +image: + model: + base_url: + api_key: '' + +video: + model: + base_url: + api_key: '' + +# Optional. Fill these only when using novel2video planning. +embedding: + model_provider: openai + model: + base_url: + api_key: '' + +# Optional. Fill these only when using novel2video planning. +reranker: + model: + base_url: + api_key: '' diff --git a/configs/agent.local.yaml b/configs/agent.local.yaml new file mode 100644 index 0000000..8f5ea4f --- /dev/null +++ b/configs/agent.local.yaml @@ -0,0 +1,31 @@ +# ViMax agent runtime local configuration template. +# Keep real keys out of commits. Prefer environment variables for shared or CI usage. + +llm: + model_provider: openai + model: + base_url: + api_key: '' + +image: + model: + base_url: + api_key: '' + +video: + model: + base_url: + api_key: '' + +# Optional. Fill these only when using novel2video planning. +embedding: + model_provider: openai + model: + base_url: + api_key: '' + +# Optional. Fill these only when using novel2video planning. +reranker: + model: + base_url: + api_key: '' diff --git a/configs/idea2video.yaml b/configs/idea2video.yaml new file mode 100644 index 0000000..754a5b4 --- /dev/null +++ b/configs/idea2video.yaml @@ -0,0 +1,32 @@ +chat_model: + init_args: + model: google/gemini-2.5-flash-lite-preview-09-2025 + model_provider: openai + api_key: + base_url: https://openrouter.ai/api/v1 + # Rate limits for chat model API calls + # Set to null to disable rate limiting for this service + max_requests_per_minute: 500 + max_requests_per_day: 2000 + +image_generator: + class_path: tools.ImageGeneratorNanobananaGoogleAPI + init_args: + api_key: + # Rate limits for image generation API calls + # Set to null to disable rate limiting for this service + max_requests_per_minute: 10 + max_requests_per_day: 500 + + +video_generator: + class_path: tools.VideoGeneratorVeoGoogleAPI + init_args: + api_key: + # Rate limits for video generation API calls + # Set to null to disable rate limiting for this service + max_requests_per_minute: 2 + max_requests_per_day: 10 + + +working_dir: .working_dir/idea2video diff --git a/configs/idea2video_minimax.yaml b/configs/idea2video_minimax.yaml new file mode 100644 index 0000000..0343603 --- /dev/null +++ b/configs/idea2video_minimax.yaml @@ -0,0 +1,33 @@ +# Example configuration using MiniMax as the chat model provider. +# MiniMax M3 is used via its OpenAI-compatible API. +# +# Set your API key below or export MINIMAX_API_KEY in the environment. +# Available models: +# - MiniMax-M3 (latest, recommended) +# - MiniMax-M2.7 (previous generation) +# - MiniMax-M2.7-highspeed (fast variant) + +chat_model: + init_args: + model: MiniMax-M3 + model_provider: minimax + api_key: # leave empty to use the MINIMAX_API_KEY environment variable + # base_url is auto-resolved to https://api.minimax.io/v1 + max_requests_per_minute: 500 + max_requests_per_day: 2000 + +image_generator: + class_path: tools.ImageGeneratorNanobananaGoogleAPI + init_args: + api_key: + max_requests_per_minute: 10 + max_requests_per_day: 500 + +video_generator: + class_path: tools.VideoGeneratorVeoGoogleAPI + init_args: + api_key: + max_requests_per_minute: 2 + max_requests_per_day: 10 + +working_dir: .working_dir/idea2video diff --git a/configs/script2video.yaml b/configs/script2video.yaml new file mode 100644 index 0000000..718deea --- /dev/null +++ b/configs/script2video.yaml @@ -0,0 +1,33 @@ +chat_model: + init_args: + model: google/gemini-2.5-flash-lite-preview-09-2025 + model_provider: openai + api_key: + base_url: https://openrouter.ai/api/v1 + # Rate limits for chat model API calls + # Set to null to disable rate limiting for this service + max_requests_per_minute: null + max_requests_per_day: null + + +image_generator: + class_path: tools.ImageGeneratorNanobananaGoogleAPI + init_args: + api_key: + # Rate limits for image generation API calls + # Set to null to disable rate limiting for this service + max_requests_per_minute: 2 + max_requests_per_day: 50 + + +video_generator: + class_path: tools.VideoGeneratorVeoGoogleAPI + init_args: + api_key: + # Rate limits for video generation API calls + # Set to null to disable rate limiting for this service + max_requests_per_minute: 2 + max_requests_per_day: 50 + + +working_dir: .working_dir/script2video diff --git a/configs/script2video_minimax.yaml b/configs/script2video_minimax.yaml new file mode 100644 index 0000000..8dcdd73 --- /dev/null +++ b/configs/script2video_minimax.yaml @@ -0,0 +1,33 @@ +# Example configuration using MiniMax as the chat model provider. +# MiniMax M3 is used via its OpenAI-compatible API. +# +# Set your API key below or export MINIMAX_API_KEY in the environment. +# Available models: +# - MiniMax-M3 (latest, recommended) +# - MiniMax-M2.7 (previous generation) +# - MiniMax-M2.7-highspeed (fast variant) + +chat_model: + init_args: + model: MiniMax-M3 + model_provider: minimax + api_key: # leave empty to use the MINIMAX_API_KEY environment variable + # base_url is auto-resolved to https://api.minimax.io/v1 + max_requests_per_minute: null + max_requests_per_day: null + +image_generator: + class_path: tools.ImageGeneratorNanobananaGoogleAPI + init_args: + api_key: + max_requests_per_minute: 2 + max_requests_per_day: 50 + +video_generator: + class_path: tools.VideoGeneratorVeoGoogleAPI + init_args: + api_key: + max_requests_per_minute: 2 + max_requests_per_day: 50 + +working_dir: .working_dir/script2video diff --git a/interfaces/__init__.py b/interfaces/__init__.py new file mode 100644 index 0000000..17c97bf --- /dev/null +++ b/interfaces/__init__.py @@ -0,0 +1,22 @@ +from .camera import Camera +from .character import CharacterInScene, CharacterInEvent, CharacterInNovel +from .event import Event +from .frame import Frame +from .image_output import ImageOutput +from .scene import Scene +from .shot_description import ShotDescription, ShotBriefDescription +from .video_output import VideoOutput + +__all__ = [ + "Camera", + "CharacterInScene", + "CharacterInEvent", + "CharacterInNovel", + "Event", + "Frame", + "ImageOutput", + "Scene", + "ShotBriefDescription", + "ShotDescription", + "VideoOutput", +] \ No newline at end of file diff --git a/interfaces/camera.py b/interfaces/camera.py new file mode 100644 index 0000000..4081ecb --- /dev/null +++ b/interfaces/camera.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Union, Dict, Tuple + + + +class Camera(BaseModel): + idx: int = Field( + description="The index of the camera in the scene, starting from 0.", + ) + + active_shot_idxs: List[int] = Field( + description="The indices of the shots that the camera can film.", + ) + + parent_cam_idx: Optional[int] = Field( + default=None, + description="The index of the parent camera. If the camera has no parent, set this to None.", + ) + + parent_shot_idx: Optional[int] = Field( + default=None, + description="The index of the dependent shot. If the camera has no parent, set this to None.", + ) + + reason: Optional[str] = Field( + default=None, + description="The reason for the selection of the parent camera. If the camera has no parent, set this to None.", + ) + + parent_shot_idx: Optional[int] = Field( + default=None, + description="The index of the dependent shot. If the camera has no parent, set this to None.", + ) + + is_parent_fully_covers_child: Optional[bool] = Field( + default=None, + description="Whether the parent camera fully covers the child camera's content. If the camera has no parent, set this to None.", + ) + + missing_info: Optional[str] = Field( + default=None, + description="The missing information in the child shot that is not covered by the parent shot. If the parent shot fully covers the child shot, set this to None.", + ) diff --git a/interfaces/character.py b/interfaces/character.py new file mode 100644 index 0000000..061be0e --- /dev/null +++ b/interfaces/character.py @@ -0,0 +1,100 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Union, Dict +from PIL import Image + + + + +class CharacterInScene(BaseModel): + idx: int = Field( + description="The index of the character in the scene, starting from 0", + ) + identifier_in_scene: str = Field( + description="The identifier for the character in this specific scene, which may differ from the base identifier", + examples=["Alice", "Bob the Builder"], + ) + is_visible: bool = Field( + description="Indicates whether the character is visible in this scene", + examples=[True, False], + ) + static_features: str = Field( + description="The static features of the character in this specific scene, such as facial features and body shape that remain constant or are rarely changed. If the character is not visible, this field can be left empty.", + examples=[ + "Alice has long blonde hair and blue eyes, and is of slender build.", + "Bob the Builder is a middle-aged man with a sturdy build.", + ] + ) + dynamic_features: str = Field( + description="The dynamic features of the character in this specific scene, such as clothing and accessories that may change from scene to scene. If not mentioned, this field can be left empty. If the character is not visible, this field should be None.", + examples=[ + "Wearing a red scarf and a black leather jacket", + ] + ) + + def __str__(self): + # Alice[visible] + # static features: Alice has long blonde hair and blue eyes, and is of slender build. + # dynamic features: Wearing a red scarf and a black leather jacket + + s = f"{self.identifier_in_scene}" + s += "[visible]" if self.is_visible else "[not visible]" + s += "\n" + s += f"static features: {self.static_features}\n" + s += f"dynamic features: {self.dynamic_features}\n" + + return s + + + +class CharacterInEvent(BaseModel): + index: int = Field( + description="The index of the character in the event, starting from 0", + ) + identifier_in_event: str = Field( + description="The unique identifier for the character in the event", + examples=["Alice", "Bob the Builder"], + ) + + active_scenes: Dict[int, str] = Field( + description="A dictionary mapping scene indices to their identifiers in specific scenes.", + examples=[ + {0: "Alice", 2: "Alice in Wonderland", 5: "Alice"}, + {1: "Bob the Builder", 3: "Bob", 4: "Bob"}, + ] + ) + + static_features: str = Field( + description="The static features of the character in the event, such as facial features and body shape that remain constant or are rarely changed.", + examples=[ + "Alice has long blonde hair and blue eyes, and is of slender build. She often wears casual, comfortable clothing.", + "Bob the Builder is a middle-aged man with a sturdy build. He typically wears a hard hat and work overalls.", + ] + ) + + + +class CharacterInNovel(BaseModel): + index: int = Field( + description="The index of the character in the novel, starting from 0", + ) + identifier_in_novel: str = Field( + description="The unique identifier for the character in the novel", + examples=["Alice", "Bob the Builder"], + ) + + active_events: Dict[int, str] = Field( + description="A dictionary mapping event indices to their identifiers in specific events.", + examples=[ + {0: "Alice", 2: "Alice in Wonderland", 5: "Alice"}, + {1: "Bob the Builder", 3: "Bob", 4: "Bob"}, + ] + ) + + static_features: str = Field( + description="The static features of the character in the novel, such as facial features and body shape that remain constant or are rarely changed.", + examples=[ + "Alice has long blonde hair and blue eyes, and is of slender build. She often wears casual, comfortable clothing.", + "Bob the Builder is a middle-aged man with a sturdy build. He typically wears a hard hat and work overalls.", + ] + ) + diff --git a/interfaces/environment.py b/interfaces/environment.py new file mode 100644 index 0000000..2ecebe6 --- /dev/null +++ b/interfaces/environment.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Union, Dict +from PIL import Image + + + +class EnvironmentInScene(BaseModel): + slugline: str = Field( + description="The slugline of the scene, indicating the location and time of day", + examples=[ + "INT. COFFEE SHOP - NIGHT", + "EXT. PARK - DAY", + ] + ) + description: str = Field( + description="A detailed description of the environment in the specific scene. Don't describe any characters or actions here, just the setting.", + examples=[ + "The warm yellow light glowed against the mottled brick wall, while raindrops streaked the glass window with blurred neon reflections. Among the empty booths sat a lone half-finished iced latte—its foam collapsed, a faint lipstick mark on the rim. beads of condensation gleamed on the stainless steel espresso machine, and the record player's turntable rotated slowly in the shadows. A patch of wet floor shimmered with hazy reflected light.", + ] + ) + + def __str__(self): + s = f"{self.slugline} -- {self.description}" + return s + + diff --git a/interfaces/event.py b/interfaces/event.py new file mode 100644 index 0000000..309d5f0 --- /dev/null +++ b/interfaces/event.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Union, Dict + + + +class Event(BaseModel): + index: int = Field( + description="The index of the event, starting from 0", + ) + + is_last: bool = Field( + description="Indicates if this is the last event in the sequence" + ) + + description: str = Field( + description="A concise description of the event, capturing its essence in one sentence", + examples=[ + "A thief who stole a gem from a museum was caught after a rooftop chase with guards, and the gem was recovered.", + ] + ) + + process_chain: List[str] = Field( + description="A list of steps or actions that make up the event's process chain, which constitutes a complete causal chain.", + examples=[ + [ + "A thief steals a gem from a museum, triggering the alarm. Guards notice and begin the chase.", + "The thief rushes out the museum's back door and dashes through narrow alleys, with guards closely pursuing and calling for backup.", + "The thief climbs a fire escape to the rooftops; the guards follow using low platforms on adjacent buildings.", + "The thief leaps across a 1.5-meter gap between two buildings. The guards hesitate but take the risky jump, nearly losing their footing.", + "The thief knocks over stacked wooden planks to create an obstacle. The guards dodge but lose speed.", + "The thief attempts to slide down a rope to the opposite rooftop, but a guard lunges and grabs their ankle. Both tumble and grapple.", + "Backup arrives, subduing the thief and recovering the gem.", + ], + ] + ) + + def __str__(self): + s = f"" + s += f"\nDescription: {self.description}" + s += f"\nProcess Chain:" + for process in self.process_chain: + s += f"\n- {process}" + return s \ No newline at end of file diff --git a/interfaces/frame.py b/interfaces/frame.py new file mode 100644 index 0000000..15993eb --- /dev/null +++ b/interfaces/frame.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Union, Dict, Tuple, Literal + + +class Frame(BaseModel): + shot_idx: int = Field( + description="The index of the shot in the sequence, starting from 0." + ) + + frame_type: Literal["first", "last"] = Field( + description="The type of the frame, 'first' for the first frame of the shot, 'last' for the last frame of the shot." + ) + + cam_idx: int = Field( + description="The index of the camera used for this frame, starting from 0." + ) + + vis_char_idxs: List[int] = Field( + description="A list of indices of characters that are visible in this frame, corresponding to the character list provided in the input." + ) diff --git a/interfaces/image_output.py b/interfaces/image_output.py new file mode 100644 index 0000000..52aceb1 --- /dev/null +++ b/interfaces/image_output.py @@ -0,0 +1,61 @@ +import base64 +import cv2 +from typing import List, Literal, Optional, Union +from PIL import Image + +from utils.image import download_image + + + +class ImageOutput: + fmt: Literal["b64", "url", "pil", "np"] + ext: str = "png" + data: Union[str, Image.Image] + + def __init__( + self, + fmt: Literal["b64", "url", "pil", "np"], + ext: str, + data: Union[str, Image.Image], + ): + self.fmt = fmt + self.ext = ext + self.data = data + + + def save_b64(self, path: str) -> None: + """Save a base64 encoded image to the specified path. + + Args: + path (str): Path where the image will be saved. + """ + with open(path, 'wb') as f: + f.write(base64.b64decode(self.data)) + + def save_url(self, path: str) -> None: + """Download and save an image from a URL to the specified path. + + Args: + path (str): Path where the image will be saved. + """ + download_image(self.data, path) + + def save_pil(self, path: str) -> None: + """Save a PIL Image to the specified path. + + Args: + path (str): Path where the image will be saved. + """ + self.data.save(path) + + def save_np(self, path: str) -> None: + """Save a numpy array to the specified path. + + Args: + path (str): Path where the image will be saved. + """ + cv2.imencode('.png', self.data)[1].tofile(path) + + def save(self, path: str) -> None: + save_func = getattr(self, f"save_{self.fmt}") + save_func(path) \ No newline at end of file diff --git a/interfaces/scene.py b/interfaces/scene.py new file mode 100644 index 0000000..e47c45d --- /dev/null +++ b/interfaces/scene.py @@ -0,0 +1,57 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Literal, Tuple +from interfaces.environment import EnvironmentInScene +from interfaces.character import CharacterInScene + + +class Scene(BaseModel): + idx: int = Field( + description="The scene index, starting from 0", + examples=[0, 1, 2], + ) + is_last: bool = Field( + description="Indicates if this is the last scene", + examples=[False, True], + ) + environment: EnvironmentInScene = Field( + description="The detailed scene setting, including location and time", + ) + characters: List[CharacterInScene] = Field( + description="A list of characters appearing in the scene, along with their dynamic features like clothing and accessories", + ) + script: str = Field( + description="The screenplay script for the scene, including character actions and dialogues. Character names in the script should be enclosed in <>, except for character names within dialogues.", + examples=[ + " paces nervously, clutching a letter. She turns to .\n: John, we need to leave tonight.\n shakes his head, stepping toward the window.\n: It's too dangerous.", + " sits quietly, observing the chaos around her. She whispers to .\n: Bob, do you think they'll find us here?\n nods slowly, his expression grim." + ], + ) + + def __str__(self): + s = f"Scene {self.idx}:" + s += f"\nEnvironment: {str(self.environment)}" + s += f"\nCharacters: {', '.join([str(c) for c in self.characters])}" + s += f"\nScript: \n{self.script}" + return s + + + +# class Scene(BaseModel): +# index: int = Field( +# description="The index of the scene within the event, starting from 0" +# ) +# character_indices: List[int] = Field( +# description="List of indices of characters appearing in this scene, including main characters, supporting characters, and extras.", +# ) +# environment_index: int = Field( +# description="The index of the environment where the scene takes place." +# ) +# key_items_indices: List[int] = Field( +# default=[], +# description="List of indices of key items involved in this scene, if any.", +# ) +# script: str = Field( +# description="The script of the scene, including actions and dialogues" +# ) + + diff --git a/interfaces/shot_description.py b/interfaces/shot_description.py new file mode 100644 index 0000000..af8f34d --- /dev/null +++ b/interfaces/shot_description.py @@ -0,0 +1,185 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Literal, Tuple + + +class ShotBriefDescription(BaseModel): + idx: int = Field( + description="The index of the shot in the sequence, starting from 0.", + examples=[0, 1, 2], + ) + is_last: bool = Field( + description="Whether this is the last shot. If True, the story of the script has ended and no more shots will be planned after this one.", + examples=[False, True], + ) + + # visual + cam_idx: int = Field( + description="The index of the camera in the scene.", + examples=[0, 1, 2], + ) + visual_desc: str = Field( + description='''A vivid and detailed visual description of the shot that convey rich visual information through text. The character identifiers in the description must match those in the character list and be enclosed in angle brackets (e.g., , ). All visible characters should be described. + If there is a conversation, please write down the content of the conversation), when you meet some dialogue, you should write into the visual content description with :" " symbols and the character's features (eg. (male, late 20s, Texan accent softened by military precision, confident and energetic.) says: "Gear retracted. Flaps transitioning. Flight path stable. You are clear to climb."). + ''', + examples=[ + "An over-the-shoulder shot at eye level, positioned behind . The foreground, including 's shoulder and head, is softly blurred, directing focus onto 's face. 's subtle reactions—shifting from surprise to delight—are clearly visible. The supermarket background is gently blurred with cool fluorescent lighting.", + ] + ) + + + # audio + audio_desc: str = Field( + description="A detailed description of the audio in the shot.", + examples=[ + "[Sound Effect] Ambient sound (supermarket background noise, shopping cart wheels rolling)", + "[Speaker] Alice (Happy): Hello, how are you?", + None, + ], + ) + + # sound_effect: Optional[str] = Field( + # default=None, + # description="The sound effects used in the shot.", + # examples=[ + # "Ambient sound (supermarket background noise, shopping cart wheels rolling)", + # None, + # ], + # ) + # speaker: Optional[str] = Field( + # default=None, + # description="The speaker in the shot, if applicable. If there is no speaker, this field should be set to None.", + # examples=[ + # "Alice", + # None, + # ], + # ) + # is_speaker_lip_visible: Optional[bool] = Field( + # default=None, + # description="Indicates whether the speaker's lips are visible in the shot. If there is no speaker, this field should be set to None.", + # examples=[ + # True, + # False, + # None, + # ], + # ) + # line: Optional[str] = Field( + # default=None, + # description="The dialogue or monologue in the shot, if applicable. If there is a speaker, there must be a line. If there is no speaker, this field should be set to None.", + # examples=[ + # "Hello, how are you?", + # None, + # ], + # ) + # emotion: Optional[str] = Field( + # default=None, + # description="The emotion of the speaker when delivering the line, if applicable. If there is a speaker, there must be an emotion. If there is no speaker, this field should be set to None.", + # examples=[ + # "Happy", + # None, + # ], + # ) + + def __str__(self): + s = f"Shot {self.idx}:\n" + s += f"Camera Index: {self.cam_idx}\n" + s += f"Visual: {self.visual_desc}\n" + if self.audio_desc: + s += f"Audio: {self.audio_desc}" + return s + + +class ShotDescription(BaseModel): + idx: int = Field( + description="The index of the shot in the sequence, starting from 0." + ) + is_last: bool = Field( + description="Whether this is the last shot in the sequence. If True, no more shots will be planned after this one." + ) + + # visual + cam_idx: int = Field( + description="The index of the camera in the scene.", + examples=[0, 1, 2], + ) + visual_desc: str = Field( + description='''A vivid and detailed visual description of the shot that convey rich visual information through text. The character identifiers in the description must match those in the character list and be enclosed in angle brackets (e.g., , ). + If there is a conversation, please write down the content of the conversation), when you meet some dialogue, you should write into the visual content description with :" " symbols and the character's features (eg. (male, late 20s, Texan accent softened by military precision, confident and energetic.) says: "Gear retracted. Flaps transitioning. Flight path stable. You are clear to climb."). ''', + examples=[ + "An over-the-shoulder shot at eye level, positioned behind . The foreground, including 's shoulder and head, is softly blurred, directing focus onto 's face. 's subtle reactions—shifting from surprise to delight—are clearly visible. The supermarket background is gently blurred with cool fluorescent lighting.", + ] + ) + variation_type: Literal["large", "medium", "small"] = Field( + description="Indicates the degree of change in the shot's content.", + examples=["large", "medium", "small"], + ) + variation_reason: str = Field( + description="The reason for the variation type of the shot.", + examples=[ + "This is a transition shot where the content of the first frame and the last frame differs dramatically. So the variation type is large.", + "Compared to the first frame, a new character appears in the last frame, and there are no significant changes in the composition. So the variation type is medium.", + "Compared to the first frame, there are only minor changes in the composition. So the variation type is small.", + "This shot only shows Alice speaking and the changes in her facial expressions, thus the variation type is small.", + ], + ) + + ff_desc: str = Field( + description="The first frame of the shot.", + examples=[ + "Medium shot of a supermarket aisle at eye level. Bob(a tall man wearing a blue shirt and jeans) is positioned on the right side of the frame, captured in profile and facing right, while Alice(a young woman with short hair, wearing a green dress) is on the left, shown pushing a shopping cart with her gaze lowered toward the ground. They are arranged in a front-to-back spatial relationship. Shelves line both sides of the frame, and cool-toned fluorescent lighting from above washes over the scene. The vibrant colors of product packaging contrast with the metallic gray of the shopping cart, all contained within a stable, horizontally balanced composition.", + "Extreme long shot. Aerial view from hundreds of meters above the ground. The boundless golden desert resembles undulating frozen waves, occupying the vast majority of the frame. At the very center of the image, a tiny, solitary explorer appears only as a faint dark speck, dragging a long, lonely trail of footprints behind him, stretching all the way to the edge of the frame.", + "Medium shot at eye level angle. Designer A(with a beard, wearing a white suit) leans forward passionately, speaking emphatically. Product Manager B(with a beard, wearing a white T-shirt) sits with crossed arms, looking skeptical. Between them, Development Engineer C(brown hair, wearing a blue T-shirt) appears anxious, glancing between the two. Project Manager D(curly hair, wearing a red T-shirt) prepares to mediate, focusing on a whiteboard. Bright overhead lighting highlights their expressions, with a blurred whiteboard and glass wall in the background.", + "A low-angle close-up shot captures the figure from below, framing him from the chest up. His face appears resolute and commanding, his eyes piercing as he speaks passionately. Flecks of saliva are visible, emphasizing his intensity. The overcast sky breaks with occasional light, casting him as a heroic, almost monumental figure against the gloom.", + "An extremely close-up of an old, motionless pocket watch. Soft light highlights scratches on its brass case and the enamel dial with Roman numerals. The second hand remains fixed at 'VIII', casting a sharp shadow. A wrinkled finger gently touches the glass surface, evoking a tangible sense of stillness and time.", + "An over-the-shoulder shot at eye level, positioned behind Character A(red hair, wearing a white T-shirt). The foreground, including A's shoulder and head, is softly blurred, directing focus onto Character B(with a beard, wearing a white T-shirt)'s face. B's subtle reactions—shifting from surprise to confusion, then to a glimmer of understanding—are clearly visible. The café background is gently blurred with warm lighting.", + ] + ) + ff_vis_char_idxs: List[int] = Field( + default=[], + description="The indices of the characters in the first frame.", + examples=[ + [0, 1], + [0], + [], + ], + ) + lf_desc: str = Field( + description="The last frame of the shot.", + ) + lf_vis_char_idxs: List[int] = Field( + default=[], + description="The indices of the characters in the last frame.", + ) + motion_desc: str = Field( + description='''The motion description of the shot. + If there is a conversation, please write down the content of the conversation), when you meet some dialogue, you should write into the visual content description with :" " symbols and the character's features (eg. SLING (male, late 20s, Texan accent softened by military precision, confident and energetic.) says: "Gear retracted. Flaps transitioning. Flight path stable. You are clear to climb."). If there is a narration, you should write into the visual content description with :" " symbols and the narration's features (eg. Narration: "Everything is looking good. "). ''', + ) + + # audio + audio_desc: str = Field( + description="A detailed description of the audio in the shot.", + examples=[ + "[Sound Effect] Ambient sound (supermarket background noise, shopping cart wheels rolling)", + "[Speaker] Alice (Happy): Hello, how are you?", + None, + ], + ) + # sound_effect: Optional[str] = Field( + # default=None, + # description="The sound effects used in the shot. For example, a door creaking or footsteps approaching.", + # ) + # speaker: Optional[str] = Field( + # default=None, + # description="The speaker in the shot, if applicable. If there is no speaker, this field should be set to None.", + # ) + # is_speaker_lip_visible: Optional[bool] = Field( + # default=None, + # description="Indicates whether the speaker's lips are visible in the shot. If there is no speaker, this field should be set to None.", + # ) + # line: Optional[str] = Field( + # default=None, + # description="The dialogue or monologue in the shot, if applicable. If there is a speaker, there must be a line. If there is no speaker, this field should be set to None.", + # ) + # emotion: Optional[str] = Field( + # default=None, + # description="The emotion of the speaker when delivering the line, if applicable. If there is a speaker, there must be an emotion. If there is no speaker, this field should be set to None.", + # ) diff --git a/interfaces/video_output.py b/interfaces/video_output.py new file mode 100644 index 0000000..e23b255 --- /dev/null +++ b/interfaces/video_output.py @@ -0,0 +1,43 @@ +import asyncio +from typing import List, Literal, Optional, Union +from PIL import Image + +from utils.video import download_video + + +class VideoOutput: + fmt: Literal["url", "bytes"] + ext: str = "mp4" + data: Union[str, bytes] + + def __init__( + self, + fmt: Literal["url", "bytes"], + ext: str, + data: Union[str, bytes], + ): + self.fmt = fmt + self.ext = ext + self.data = data + + def save_url(self, path: str) -> None: + """Download and save a video from a URL to the specified path. + + Args: + path (str): Path where the video will be saved. + """ + download_video(self.data, path) + + def save_bytes(self, path: str) -> None: + """Save a bytes object to the specified path. + + Args: + path (str): Path where the video will be saved. + """ + with open(path, 'wb') as f: + f.write(self.data) + + def save(self, path: str) -> None: + save_func = getattr(self, f"save_{self.fmt}") + save_func(path) + diff --git a/main_agent.py b/main_agent.py new file mode 100644 index 0000000..96efdca --- /dev/null +++ b/main_agent.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from typing import Any, Iterable +from uuid import uuid4 + +ORIGINAL_STDOUT = sys.stdout + + +def event_stdout(): + if sys.stdout.__class__.__name__ == "_DiscardStream": + return ORIGINAL_STDOUT + return sys.stdout + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the ViMax agent loop.") + parser.add_argument("--session", default="", help="Existing session id to activate before the run starts.") + parser.add_argument("--new-session", action="store_true", help="Create and activate a new empty session before the run starts.") + parser.add_argument("--jsonl", action="store_true", help="Print one JSON event per line.") + parser.add_argument("--once", default="", help="Run a single prompt and exit. If omitted and stdin is not a TTY, stdin is consumed as one prompt.") + parser.add_argument("--stdin-repl", action="store_true", help=argparse.SUPPRESS) + return parser.parse_args(argv) + + +def load_runtime(): + from agent_runtime import build_runtime + + return build_runtime(".") + + +def load_session_index(): + from agent_runtime.session_index import SessionIndex + + return SessionIndex(".") + + +def print_event(event: dict[str, Any], *, jsonl: bool) -> None: + out = event_stdout() + if jsonl: + print(json.dumps(event, ensure_ascii=False, default=str), file=out, flush=True) + return + event_type = event.get("type") + if event_type == "turn": + print(f"· turn: {event.get('turn_id', '')}", file=out, flush=True) + elif event_type == "token": + print(event.get("delta", ""), end="", file=out, flush=True) + elif event_type == "tool_start": + tool = event.get("tool", {}) + print(f"\n· tool: {tool.get('name')} started", file=out, flush=True) + elif event_type == "tool_progress": + progress = event.get("progress", {}) + tool = event.get("tool", {}) + print(f"· tool: {tool.get('name')} {progress.get('stage', 'running')}: {progress.get('message', '')}", file=out, flush=True) + elif event_type == "tool_result": + result = event["tool_result"] + status = "done" if result.get("ok") else "error" + print(f"· tool: {result.get('name')} {status}", file=out, flush=True) + elif event_type == "terminal": + stream = event.get("stream", "stdout") + print(f"· terminal[{stream}]: {event.get('line', '')}", file=out, flush=True) + elif event_type == "status": + print(f"· status: {event.get('phase')}: {event.get('message', '')}", file=out, flush=True) + elif event_type == "session": + session = (event.get("session") or {}).get("session") or {} + if session: + print(f"· session: {session.get('session_id')} {session.get('stage', '')}", file=out, flush=True) + elif event_type == "done": + print("", file=out, flush=True) + elif event_type == "error": + print(f"\nerror: {event.get('message', '')}", file=out, flush=True) + + +def prompt_inputs(args: argparse.Namespace) -> Iterable[str]: + if args.once: + yield args.once + return + if args.stdin_repl: + for line in sys.stdin: + user_input = line.strip() + if user_input: + yield user_input + return + if not sys.stdin.isatty(): + payload = sys.stdin.read().strip() + if payload: + yield payload + return + while True: + try: + user_input = input("› " if not args.jsonl else "") + except EOFError: + break + if user_input.strip(): + yield user_input.strip() + + +async def amain(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if args.session and args.new_session: + print("error: --session and --new-session cannot be used together", file=sys.stderr) + return 2 + if args.session or args.new_session: + try: + session_index = load_session_index() + if args.new_session: + session_index.create() + else: + session_index.set_active(args.session) + except KeyError: + print(f"error: unknown session id: {args.session}", file=sys.stderr) + return 2 + except ValueError as exc: + print(f"error: invalid session id: {exc}", file=sys.stderr) + return 2 + runtime = load_runtime() + interactive = sys.stdin.isatty() and not args.once + if interactive and not args.jsonl: + print("ViMax agent ready. Ctrl+C to exit.") + for user_input in prompt_inputs(args): + if user_input.strip() == "/compact": + turn_id = f"turn-{uuid4().hex[:12]}" + print_event({"type": "turn", "turn_id": turn_id, "turn": {"id": turn_id}}, jsonl=args.jsonl) + print_event({"type": "status", "turn_id": turn_id, "phase": "compact", "message": "Compacting context"}, jsonl=args.jsonl) + message = await runtime.compact_history(reason="manual") + print_event({"type": "token", "turn_id": turn_id, "delta": message}, jsonl=args.jsonl) + print_event({"type": "done", "turn_id": turn_id, "assistant": message, "tool_results": []}, jsonl=args.jsonl) + print_event({"type": "session", "turn_id": turn_id, "session": runtime.session_index.snapshot()}, jsonl=args.jsonl) + continue + try: + async for event in runtime.stream_events(user_input): + print_event(event, jsonl=args.jsonl) + except Exception as exc: + # Keep the REPL alive: one failed turn must not kill the process + # (and with it the TUI driving us over stdio). + turn_id = f"turn-{uuid4().hex[:12]}" + print_event({"type": "error", "turn_id": turn_id, "message": f"turn failed: {exc}"}, jsonl=args.jsonl) + print_event({"type": "done", "turn_id": turn_id, "assistant": "", "tool_results": []}, jsonl=args.jsonl) + return 0 + + +def main() -> None: + try: + raise SystemExit(asyncio.run(amain())) + except KeyboardInterrupt: + print("", file=sys.stderr) + raise SystemExit(130) + + +if __name__ == "__main__": + main() diff --git a/main_idea2video.py b/main_idea2video.py new file mode 100644 index 0000000..194aa1b --- /dev/null +++ b/main_idea2video.py @@ -0,0 +1,27 @@ +import asyncio +from pipelines.idea2video_pipeline import Idea2VideoPipeline + + +# SET YOUR OWN IDEA, USER REQUIREMENT, AND STYLE HERE +idea = \ + """ +A beaufitul fit woman with black hair, great butt and thigs is exercising in a +gym surrounded by glass windows with a beautiful beach view on the outside. +She is performing glute exercises that highlight her beautiful back and sexy outfit +and showing the audience the proper form. Between the 3 different exercises she looks +at the camera with a gorgeous look asking the viewer understood the proper form. +""" +user_requirement = \ + """ +For adults, do not exceed 3 scenes. Each scene should be no more than 5 shots. +""" +style = "Realistic, warm feel" + + +async def main(): + pipeline = Idea2VideoPipeline.init_from_config( + config_path="configs/idea2video.yaml") + await pipeline(idea=idea, user_requirement=user_requirement, style=style) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/main_script2video.py b/main_script2video.py new file mode 100644 index 0000000..cd6cf7b --- /dev/null +++ b/main_script2video.py @@ -0,0 +1,34 @@ +import asyncio +from pipelines.script2video_pipeline import Script2VideoPipeline + + +# SET YOUR OWN SCRIPT, USER REQUIREMENT, AND STYLE HERE +script = \ +""" +EXT. SCHOOL GYM - DAY +A group of students are practicing basketball in the gym. The gym is large and open, with a basketball hoop at one end and a large crowd of spectators at the other end. John (18, male, tall, athletic) is the star player, and he is practicing his dribble and shot. Jane (17, female, short, athletic) is the assistant coach, and she is helping John with his practice. The other students are watching the practice and cheering for John. +John: (dribbling the ball) I'm going to score a basket! +Jane: (smiling) Good job, John! +John: (shooting the ball) Yes! +John:(The shot misses. He seems frustrated.) Argh! My follow-through feels off today. +Jane:(Walks over, analytical.) Your elbow is drifting out. Remember, straight as an arrow. +John:(Nods, taking the ball again.) Straight as an arrow... Let me try again. +(John takes another shot. This time, the ball swishes through the net perfectly.) +Jane:(Clapping.) There it is! Perfect form! That's the shot we need for the championship. +John:(Retrieving the ball, smiling with renewed confidence.) Thanks, Coach Jane. I just needed you to point it out. One more time? +""" +user_requirement = \ +""" +Fast-paced with no more than 15 shots. +""" +style = "Anime Style" + + + +async def main(): + pipeline = Script2VideoPipeline.init_from_config(config_path="configs/script2video.yaml") + await pipeline(script=script, user_requirement=user_requirement, style=style) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pipelines/__init__.py b/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pipelines/idea2video_pipeline.py b/pipelines/idea2video_pipeline.py new file mode 100644 index 0000000..9b7b791 --- /dev/null +++ b/pipelines/idea2video_pipeline.py @@ -0,0 +1,252 @@ +import os +import logging +from agents import Screenwriter, CharacterExtractor, CharacterPortraitsGenerator +from pipelines.script2video_pipeline import Script2VideoPipeline +from interfaces import CharacterInScene +from typing import List, Dict, Optional +import asyncio +import json +import yaml +from langchain.chat_models import init_chat_model +from tools.render_backend import RenderBackend +from utils.provider_presets import resolve_chat_model_config +from utils.text import safe_path_component +from utils.video import concatenate_video_files + + +def _pipeline_print(quiet: bool, message: str) -> None: + if not quiet: + print(message) + + +class Idea2VideoPipeline: + def __init__( + self, + chat_model: str, + image_generator: str, + video_generator: str, + working_dir: str, + ): + self.chat_model = chat_model + self.image_generator = image_generator + self.video_generator = video_generator + self.working_dir = working_dir + os.makedirs(self.working_dir, exist_ok=True) + + self.screenwriter = Screenwriter(chat_model=self.chat_model) + self.character_extractor = CharacterExtractor( + chat_model=self.chat_model) + self.character_portraits_generator = CharacterPortraitsGenerator( + image_generator=self.image_generator) + + @classmethod + def init_from_config(cls, config_path: str): + with open(config_path, "r") as f: + config = yaml.safe_load(f) + + chat_model_args = resolve_chat_model_config(config["chat_model"]["init_args"]) + chat_model = init_chat_model(**chat_model_args) + backend = RenderBackend.from_config(config) + + return cls( + chat_model=chat_model, + image_generator=backend.image_generator, + video_generator=backend.video_generator, + working_dir=config["working_dir"], + ) + + async def extract_characters( + self, + story: str, + quiet: bool = False, + ): + save_path = os.path.join(self.working_dir, "characters.json") + + if os.path.exists(save_path): + with open(save_path, "r", encoding="utf-8") as f: + characters = json.load(f) + characters = [CharacterInScene.model_validate( + character) for character in characters] + _pipeline_print(quiet, f"🚀 Loaded {len(characters)} characters from existing file.") + else: + characters = await self.character_extractor.extract_characters(story) + with open(save_path, "w", encoding="utf-8") as f: + json.dump([character.model_dump() + for character in characters], f, ensure_ascii=False, indent=4) + _pipeline_print(quiet, f"✅ Extracted {len(characters)} characters from story and saved to {save_path}.") + + return characters + + async def generate_character_portraits( + self, + characters: List[CharacterInScene], + character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]], + style: str, + ): + character_portraits_registry_path = os.path.join( + self.working_dir, "character_portraits_registry.json") + if character_portraits_registry is None: + if os.path.exists(character_portraits_registry_path): + with open(character_portraits_registry_path, 'r', encoding='utf-8') as f: + character_portraits_registry = json.load(f) + else: + character_portraits_registry = {} + + tasks = [ + self.generate_portraits_for_single_character(character, style) + for character in characters + if character.identifier_in_scene not in character_portraits_registry + ] + if tasks: + for future in asyncio.as_completed(tasks): + character_portraits_registry.update(await future) + with open(character_portraits_registry_path, 'w', encoding='utf-8') as f: + json.dump(character_portraits_registry, + f, ensure_ascii=False, indent=4) + + print( + f"✅ Completed character portrait generation for {len(characters)} characters.") + else: + print( + "🚀 All characters already have portraits, skipping portrait generation.") + + return character_portraits_registry + + async def develop_story( + self, + idea: str, + user_requirement: str, + quiet: bool = False, + ): + save_path = os.path.join(self.working_dir, "story.txt") + if os.path.exists(save_path): + with open(save_path, "r", encoding="utf-8") as f: + story = f.read() + _pipeline_print(quiet, f"🚀 Loaded story from existing file.") + else: + _pipeline_print(quiet, "🧠 Developing story...") + story = await self.screenwriter.develop_story(idea=idea, user_requirement=user_requirement) + with open(save_path, "w", encoding="utf-8") as f: + f.write(story) + _pipeline_print(quiet, f"✅ Developed story and saved to {save_path}.") + + return story + + async def write_script_based_on_story( + self, + story: str, + user_requirement: str, + quiet: bool = False, + ): + save_path = os.path.join(self.working_dir, "script.json") + if os.path.exists(save_path): + with open(save_path, "r", encoding="utf-8") as f: + script = json.load(f) + _pipeline_print(quiet, f"🚀 Loaded script from existing file.") + else: + _pipeline_print(quiet, "🧠 Writing script based on story...") + script = await self.screenwriter.write_script_based_on_story(story=story, user_requirement=user_requirement) + with open(save_path, "w", encoding="utf-8") as f: + json.dump(script, f, ensure_ascii=False, indent=4) + _pipeline_print(quiet, f"✅ Written script based on story and saved to {save_path}.") + return script + + async def generate_portraits_for_single_character( + self, + character: CharacterInScene, + style: str, + ): + character_dir = os.path.join( + self.working_dir, "character_portraits", f"{character.idx}_{safe_path_component(character.identifier_in_scene)}") + os.makedirs(character_dir, exist_ok=True) + + front_portrait_path = os.path.join(character_dir, "front.png") + if os.path.exists(front_portrait_path): + pass + else: + front_portrait_output = await self.character_portraits_generator.generate_front_portrait(character, style) + front_portrait_output.save(front_portrait_path) + + side_portrait_path = os.path.join(character_dir, "side.png") + if os.path.exists(side_portrait_path): + pass + else: + side_portrait_output = await self.character_portraits_generator.generate_side_portrait(character, front_portrait_path) + side_portrait_output.save(side_portrait_path) + + back_portrait_path = os.path.join(character_dir, "back.png") + if os.path.exists(back_portrait_path): + pass + else: + back_portrait_output = await self.character_portraits_generator.generate_back_portrait(character, front_portrait_path) + back_portrait_output.save(back_portrait_path) + + print( + f"☑️ Completed character portrait generation for {character.identifier_in_scene}.") + + return { + character.identifier_in_scene: { + "front": { + "path": front_portrait_path, + "description": f"A front view portrait of {character.identifier_in_scene}.", + }, + "side": { + "path": side_portrait_path, + "description": f"A side view portrait of {character.identifier_in_scene}.", + }, + "back": { + "path": back_portrait_path, + "description": f"A back view portrait of {character.identifier_in_scene}.", + }, + } + } + + async def __call__( + self, + idea: str, + user_requirement: str, + style: str, + quiet: bool = False, + ): + + story = await self.develop_story(idea=idea, user_requirement=user_requirement, quiet=quiet) + + characters = await self.extract_characters(story=story, quiet=quiet) + + character_portraits_registry = await self.generate_character_portraits( + characters=characters, + character_portraits_registry=None, + style=style, + ) + + scene_scripts = await self.write_script_based_on_story(story=story, user_requirement=user_requirement, quiet=quiet) + + all_video_paths = [] + + for idx, scene_script in enumerate(scene_scripts): + scene_working_dir = os.path.join(self.working_dir, f"scene_{idx}") + os.makedirs(scene_working_dir, exist_ok=True) + script2video_pipeline = Script2VideoPipeline( + chat_model=self.chat_model, + image_generator=self.image_generator, + video_generator=self.video_generator, + working_dir=scene_working_dir, + ) + final_video_path = await script2video_pipeline( + script=scene_script, + user_requirement=user_requirement, + style=style, + characters=characters, + character_portraits_registry=character_portraits_registry, + quiet=quiet, + ) + all_video_paths.append(final_video_path) + + final_video_path = os.path.join(self.working_dir, "final_video.mp4") + if os.path.exists(final_video_path): + _pipeline_print(quiet, f"🚀 Skipped concatenating videos, already exists.") + else: + _pipeline_print(quiet, f"🎬 Starting concatenating videos...") + concatenate_video_files(all_video_paths, final_video_path) + _pipeline_print(quiet, f"☑️ Concatenated videos, saved to {final_video_path}.") + return final_video_path diff --git a/pipelines/novel2movie_pipeline.py b/pipelines/novel2movie_pipeline.py new file mode 100644 index 0000000..bf17bb6 --- /dev/null +++ b/pipelines/novel2movie_pipeline.py @@ -0,0 +1,1010 @@ +# TODO: NOT IMPLEMENTED YET + +import os +import shutil +import yaml +import json +import importlib +import asyncio +from typing import Any, Callable, List, Dict +from langchain.embeddings import CacheBackedEmbeddings +from langchain.storage import LocalFileStore +from langchain_text_splitters import RecursiveCharacterTextSplitter +from langchain_community.vectorstores import FAISS +from PIL import Image + +from interfaces import ( + Event, + Scene, + CharacterInScene, + CharacterInNovel, + CharacterInEvent, +) +from tenacity import retry + +from utils.text import safe_path_component + + + +def _pipeline_print(quiet: bool, message: str) -> None: + if not quiet: + print(message) + + +def _emit_text_plan_progress(progress, stage: str, message: str, metadata: dict | None = None) -> None: + if progress is not None: + progress(stage, message, metadata or {}) + + +def _event_file_index(path: str) -> int: + return int(os.path.basename(path).split("_")[1].split(".")[0]) + + +def _scene_file_index(path: str) -> int: + return int(os.path.basename(path).split("_")[1].split(".")[0]) + +class Novel2MoviePipeline: + def __init__( + self, + novel_compressor: Any, + event_extractor: Any, + embeddings: Any, + rerank_model: Any, + scene_extractor: Any, + global_information_planner: Any, + image_generator: Any, + rewriter: Any, + script2video_pipeline: Any, + working_dir: str, + ): + self.novel_compressor = novel_compressor + self.event_extractor = event_extractor + self.embeddings = embeddings + self.rerank_model = rerank_model + self.scene_extractor = scene_extractor + self.global_information_planner = global_information_planner + self.image_generator = image_generator + self.rewriter = rewriter + self.script2video_pipeline = script2video_pipeline + self.working_dir = working_dir + os.makedirs(self.working_dir, exist_ok=True) + + + async def plan_text_artifacts( + self, + novel_text: str, + user_requirement: str = "", + style: str = "", + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + quiet: bool = False, + ) -> dict[str, Any]: + """Generate structured text artifacts for novel adaptation only. + + This helper intentionally stops before character portrait generation, + scene video generation, and final concatenation so the agent loop can + pause after the novel planning stage. + """ + del user_requirement, style + + _emit_text_plan_progress(progress, "save_novel", "Saving and splitting novel text") + working_dir_novel = os.path.join(self.working_dir, "novel") + os.makedirs(working_dir_novel, exist_ok=True) + with open(os.path.join(working_dir_novel, "novel.txt"), "w", encoding="utf-8") as f: + f.write(novel_text) + + novel_chunks = self.novel_compressor.split(novel_text) + for idx, novel_chunk in enumerate(novel_chunks): + with open(os.path.join(working_dir_novel, f"novel_chunk_{idx}.txt"), "w", encoding="utf-8") as f: + f.write(novel_chunk) + _pipeline_print(quiet, f"Split novel into {len(novel_chunks)} chunks.") + + _emit_text_plan_progress(progress, "compress_novel", "Compressing novel chunks", {"chunk_count": len(novel_chunks)}) + compressed_novel_chunks: list[str | None] = [None] * len(novel_chunks) + unfinished_pairs = [] + for index, novel_chunk in enumerate(novel_chunks): + path = os.path.join(working_dir_novel, f"novel_chunk_{index}_compressed.txt") + if os.path.exists(path): + compressed_novel_chunks[index] = open(path, "r", encoding="utf-8").read() + else: + unfinished_pairs.append((index, novel_chunk)) + if unfinished_pairs: + sem = asyncio.Semaphore(5) + outputs = await asyncio.gather(*[ + self.novel_compressor.compress_single_novel_chunk(sem, index, novel_chunk) + for index, novel_chunk in unfinished_pairs + ]) + for index, compressed in outputs: + path = os.path.join(working_dir_novel, f"novel_chunk_{index}_compressed.txt") + with open(path, "w", encoding="utf-8") as f: + f.write(compressed) + compressed_novel_chunks[index] = compressed + + compressed_path = os.path.join(working_dir_novel, "novel_compressed.txt") + if os.path.exists(compressed_path): + compressed_novel = open(compressed_path, "r", encoding="utf-8").read() + else: + compressed_novel = self.novel_compressor.aggregate([chunk or "" for chunk in compressed_novel_chunks]) + with open(compressed_path, "w", encoding="utf-8") as f: + f.write(compressed_novel) + + _emit_text_plan_progress(progress, "extract_events", "Extracting events from compressed novel") + working_dir_events = os.path.join(self.working_dir, "events") + os.makedirs(working_dir_events, exist_ok=True) + extracted_events: list[Event] = [] + event_files = [ + os.path.join(working_dir_events, fname) + for fname in os.listdir(working_dir_events) + if fname.startswith("event_") and fname.endswith(".json") + ] + for event_path in sorted(event_files, key=_event_file_index): + with open(event_path, "r", encoding="utf-8") as f: + extracted_events.append(Event.model_validate(json.load(f))) + while len(extracted_events) == 0 or not extracted_events[-1].is_last: + _ensure_extraction_cap(len(extracted_events), MAX_EXTRACTED_EVENTS, "events") + next_event = self.event_extractor.extract_next_event( + novel_text=compressed_novel, + extracted_events=extracted_events, + ) + event_path = os.path.join(working_dir_events, f"event_{len(extracted_events)}.json") + with open(event_path, "w", encoding="utf-8") as f: + json.dump(next_event.model_dump(), f, ensure_ascii=False, indent=4) + extracted_events.append(next_event) + + _emit_text_plan_progress(progress, "retrieve_chunks", "Retrieving relevant chunks for events", {"event_count": len(extracted_events)}) + working_dir_knowledge_base = os.path.join(self.working_dir, "knowledge_base") + working_dir_retrieve = os.path.join(self.working_dir, "relevant_chunks") + os.makedirs(working_dir_knowledge_base, exist_ok=True) + os.makedirs(working_dir_retrieve, exist_ok=True) + embeddings = CacheBackedEmbeddings.from_bytes_store( + underlying_embeddings=self.embeddings, + document_embedding_cache=LocalFileStore(root_path=working_dir_knowledge_base), + namespace=getattr(self.embeddings, "model", "default"), + key_encoder="sha256", + ) + novel_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=128) + knowledge_chunks = novel_splitter.split_text(novel_text) + knowledge_base = FAISS.from_texts(texts=knowledge_chunks, embedding=embeddings) + event_idx_to_relevant_chunk_score_dict: dict[int, dict[str, float]] = {} + + async def retrieve_relevant_chunks(sem, event: Event): + async with sem: + relevant: dict[str, float] = {} + for process in event.process_chain: + chunks = knowledge_base.similarity_search(process, k=10) + chunk_texts = [chunk.page_content for chunk in chunks if chunk.page_content not in relevant] + if not chunk_texts: + continue + chunk_score_pairs = await self.rerank_model(documents=chunk_texts, query=process, top_n=10) + for chunk, score in chunk_score_pairs: + if score >= 0.7: + relevant[chunk] = relevant.get(chunk, 0.0) + score + return event.index, relevant + + retrieve_tasks = [] + retrieve_sem = asyncio.Semaphore(10) + for event in extracted_events: + chunks_dir = os.path.join(working_dir_retrieve, f"event_{event.index}") + if os.path.exists(chunks_dir) and os.listdir(chunks_dir): + relevant = {} + for chunk_fname in os.listdir(chunks_dir): + chunk_path = os.path.join(chunks_dir, chunk_fname) + score = float(chunk_fname.split("-score_")[1].split(".txt")[0]) + with open(chunk_path, "r", encoding="utf-8") as f: + relevant[f.read()] = score + event_idx_to_relevant_chunk_score_dict[event.index] = relevant + else: + retrieve_tasks.append(retrieve_relevant_chunks(retrieve_sem, event)) + if retrieve_tasks: + for event_index, relevant in await asyncio.gather(*retrieve_tasks): + chunks_dir = os.path.join(working_dir_retrieve, f"event_{event_index}") + os.makedirs(chunks_dir, exist_ok=True) + for idx, (chunk, score) in enumerate(relevant.items()): + with open(os.path.join(chunks_dir, f"chunk_{idx}-score_{score:.2f}.txt"), "w", encoding="utf-8") as f: + f.write(chunk) + event_idx_to_relevant_chunk_score_dict[event_index] = relevant + + _emit_text_plan_progress(progress, "extract_scenes", "Extracting screenplay scenes", {"event_count": len(extracted_events)}) + working_dir_scenes = os.path.join(self.working_dir, "scenes") + os.makedirs(working_dir_scenes, exist_ok=True) + event_idx_to_scenes: dict[int, list[Scene]] = {event.index: [] for event in extracted_events} + unfinished_events: list[Event] = [] + for event in extracted_events: + scenes_dir = os.path.join(working_dir_scenes, f"event_{event.index}") + if os.path.exists(scenes_dir): + scene_files = [ + os.path.join(scenes_dir, fname) + for fname in os.listdir(scenes_dir) + if fname.startswith("scene_") and fname.endswith(".json") + ] + for scene_path in sorted(scene_files, key=_scene_file_index): + with open(scene_path, "r", encoding="utf-8") as f: + event_idx_to_scenes[event.index].append(Scene.model_validate(json.load(f))) + if not event_idx_to_scenes[event.index] or not event_idx_to_scenes[event.index][-1].is_last: + unfinished_events.append(event) + + async def extract_scenes_for_event(sem, event: Event, previous_scenes: list[Scene]): + async with sem: + scenes_dir = os.path.join(working_dir_scenes, f"event_{event.index}") + os.makedirs(scenes_dir, exist_ok=True) + while len(previous_scenes) == 0 or not previous_scenes[-1].is_last: + _ensure_extraction_cap(len(previous_scenes), MAX_SCENES_PER_EVENT, "scenes") + next_scene = await self.scene_extractor.get_next_scene( + relevant_chunks=list(event_idx_to_relevant_chunk_score_dict.get(event.index, {}).keys()), + event=event, + previous_scenes=previous_scenes, + ) + scene_path = os.path.join(scenes_dir, f"scene_{len(previous_scenes)}.json") + with open(scene_path, "w", encoding="utf-8") as f: + json.dump(next_scene.model_dump(), f, ensure_ascii=False, indent=4) + previous_scenes.append(next_scene) + return event.index, previous_scenes + + if unfinished_events: + sem = asyncio.Semaphore(8) + scene_outputs = await asyncio.gather(*[ + extract_scenes_for_event(sem, event, event_idx_to_scenes[event.index]) + for event in unfinished_events + ]) + for event_index, scenes in scene_outputs: + event_idx_to_scenes[event_index] = scenes + + _emit_text_plan_progress(progress, "merge_characters", "Merging scene characters into novel-level characters", {"event_count": len(extracted_events)}) + working_dir_global = os.path.join(self.working_dir, "global_information") + working_dir_characters = os.path.join(working_dir_global, "characters") + os.makedirs(working_dir_characters, exist_ok=True) + event_idx_to_characters_in_event: dict[int, list[CharacterInEvent]] = {} + + async def merge_event_characters(sem, event: Event): + async with sem: + characters = await self.global_information_planner.merge_characters_across_scenes_in_event( + event_idx=event.index, + scenes=event_idx_to_scenes[event.index], + ) + path = os.path.join(working_dir_characters, "event_level", f"event_{event.index}_characters.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump([char.model_dump() for char in characters], f, ensure_ascii=False, indent=4) + return event.index, characters + + merge_tasks = [] + merge_sem = asyncio.Semaphore(8) + for event in extracted_events: + path = os.path.join(working_dir_characters, "event_level", f"event_{event.index}_characters.json") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + event_idx_to_characters_in_event[event.index] = [CharacterInEvent.model_validate(item) for item in json.load(f)] + else: + merge_tasks.append(merge_event_characters(merge_sem, event)) + if merge_tasks: + for event_index, characters in await asyncio.gather(*merge_tasks): + event_idx_to_characters_in_event[event_index] = characters + + working_dir_novel_chars = os.path.join(working_dir_characters, "novel_level") + os.makedirs(working_dir_novel_chars, exist_ok=True) + existing_files = [fname for fname in os.listdir(working_dir_novel_chars) if fname.startswith("novel_characters_after_event_") and fname.endswith(".json")] + if existing_files: + latest = max(existing_files, key=lambda fname: int(fname.split("_")[-1].split(".json")[0])) + start_event_idx = int(latest.split("_")[-1].split(".json")[0]) + 1 + with open(os.path.join(working_dir_novel_chars, latest), "r", encoding="utf-8") as f: + characters_in_novel = [CharacterInNovel.model_validate(item) for item in json.load(f)] + else: + start_event_idx = 0 + characters_in_novel = [] + for event in extracted_events[start_event_idx:]: + characters_in_novel = self.global_information_planner.merge_characters_to_existing_characters_in_novel( + event_idx=event.index, + existing_characters_in_novel=characters_in_novel, + characters_in_event=event_idx_to_characters_in_event[event.index], + ) + path = os.path.join(working_dir_novel_chars, f"novel_characters_after_event_{event.index}.json") + with open(path, "w", encoding="utf-8") as f: + json.dump([char.model_dump() for char in characters_in_novel], f, ensure_ascii=False, indent=4) + + _emit_text_plan_progress(progress, "completed", "Novel structured text planning complete", {"event_count": len(extracted_events)}) + return { + "compressed_novel": compressed_novel, + "events": extracted_events, + "scenes": event_idx_to_scenes, + "characters_in_novel": characters_in_novel, + } + + + async def render_video_artifacts( + self, + style: str, + user_requirement: str = "", + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + quiet: bool = False, + ) -> dict[str, Any]: + """Render portraits and per-scene videos from existing novel planning artifacts. + + This helper assumes plan_text_artifacts has already completed. It does not + re-run compression, event extraction, RAG retrieval, scene extraction, or + character merging. + """ + del user_requirement + + _emit_text_plan_progress(progress, "novel_render_load", "Loading novel structured text artifacts") + working_dir_events = os.path.join(self.working_dir, "events") + working_dir_scenes = os.path.join(self.working_dir, "scenes") + working_dir_characters = os.path.join(self.working_dir, "global_information", "characters") + event_level_dir = os.path.join(working_dir_characters, "event_level") + novel_level_dir = os.path.join(working_dir_characters, "novel_level") + + if not os.path.isdir(working_dir_events): + raise RuntimeError("novel2video/events is missing; run vimax_novel_planning first") + if not os.path.isdir(working_dir_scenes): + raise RuntimeError("novel2video/scenes is missing; run vimax_novel_planning first") + if not os.path.isdir(event_level_dir) or not os.path.isdir(novel_level_dir): + raise RuntimeError("novel2video/global_information/characters is missing; run vimax_novel_planning first") + + event_files = [ + os.path.join(working_dir_events, fname) + for fname in os.listdir(working_dir_events) + if fname.startswith("event_") and fname.endswith(".json") + ] + extracted_events = [] + for event_path in sorted(event_files, key=_event_file_index): + with open(event_path, "r", encoding="utf-8") as f: + extracted_events.append(Event.model_validate(json.load(f))) + if not extracted_events: + raise RuntimeError("novel2video/events has no event_*.json files") + + event_idx_to_scenes: dict[int, list[Scene]] = {} + for event in extracted_events: + scenes_dir = os.path.join(working_dir_scenes, f"event_{event.index}") + if not os.path.isdir(scenes_dir): + raise RuntimeError(f"novel2video/scenes/event_{event.index} is missing") + scene_files = [ + os.path.join(scenes_dir, fname) + for fname in os.listdir(scenes_dir) + if fname.startswith("scene_") and fname.endswith(".json") + ] + scenes = [] + for scene_path in sorted(scene_files, key=_scene_file_index): + with open(scene_path, "r", encoding="utf-8") as f: + scenes.append(Scene.model_validate(json.load(f))) + if not scenes: + raise RuntimeError(f"novel2video/scenes/event_{event.index} has no scene_*.json files") + event_idx_to_scenes[event.index] = scenes + + event_idx_to_characters_in_event: dict[int, list[CharacterInEvent]] = {} + for event in extracted_events: + path = os.path.join(event_level_dir, f"event_{event.index}_characters.json") + if not os.path.exists(path): + raise RuntimeError(f"novel2video/global_information/characters/event_level/event_{event.index}_characters.json is missing") + with open(path, "r", encoding="utf-8") as f: + event_idx_to_characters_in_event[event.index] = [CharacterInEvent.model_validate(item) for item in json.load(f)] + + novel_files = [fname for fname in os.listdir(novel_level_dir) if fname.startswith("novel_characters_after_event_") and fname.endswith(".json")] + if not novel_files: + raise RuntimeError("novel2video/global_information/characters/novel_level has no novel characters file") + latest_novel_file = max(novel_files, key=lambda fname: int(fname.split("_")[-1].split(".json")[0])) + with open(os.path.join(novel_level_dir, latest_novel_file), "r", encoding="utf-8") as f: + characters_in_novel = [CharacterInNovel.model_validate(item) for item in json.load(f)] + + _emit_text_plan_progress(progress, "novel_portraits_start", "Generating novel character portraits", {"character_count": len(characters_in_novel)}) + working_dir_character_portrait = os.path.join(self.working_dir, "character_portraits") + base_character_portrait_dir = os.path.join(working_dir_character_portrait, "base") + os.makedirs(base_character_portrait_dir, exist_ok=True) + + async def generate_base_portrait(sem, character: CharacterInNovel): + async with sem: + image_path = os.path.join(base_character_portrait_dir, f"character_{character.index}_{safe_path_component(character.identifier_in_novel)}.png") + if os.path.exists(image_path): + return image_path + prompt = f"Generate a full-body, front-view portrait based on the following description, in the style of {style}:" + prompt += f"\nCharacter Identifier: {character.identifier_in_novel}" + prompt += f"\nFeatures: {character.static_features}" + prompt += "\nThe character should be centered in the image, occupying most of the frame. Gazing straight ahead. Standing with arms relaxed at sides. Natural expression. The background should be plain white." + image = await self.image_generator.generate_single_image(prompt=prompt, size="512x512") + image.save(image_path) + return image_path + + sem = asyncio.Semaphore(5) + await asyncio.gather(*[generate_base_portrait(sem, character) for character in characters_in_novel]) + _emit_text_plan_progress(progress, "novel_portraits_base_done", "Base character portraits ready", {"character_count": len(characters_in_novel)}) + + async def generate_scene_portrait(sem, base_character_image_path: str, character: CharacterInScene, event_idx: int, scene_idx: int): + async with sem: + image_path = os.path.join(working_dir_character_portrait, f"event_{event_idx}", f"scene_{scene_idx}", f"character_{character.idx}_{safe_path_component(character.identifier_in_scene)}.png") + os.makedirs(os.path.dirname(image_path), exist_ok=True) + if os.path.exists(image_path): + return image_path + if not character.is_visible or character.dynamic_features is None: + shutil.copy(base_character_image_path, image_path) + return image_path + prompt = f"Generate a full-body, front-view portrait based on the provided base image. Modify the base image according to the following dynamic features, in the style of {style}. Keep the character's identity consistent with the base image:" + prompt += f"\nCharacter Identifier: {character.identifier_in_scene}" + prompt += f"\nDynamic Features: {character.dynamic_features}" + prompt += "\nThe character should be centered in the image, occupying most of the frame. Gazing straight ahead. Standing with arms relaxed at sides. Natural expression. The background should be plain white." + prompt = await self.rewriter(prompt) + image = await self.image_generator.generate_single_image(prompt=prompt, reference_image_paths=[base_character_image_path], size="512x512") + image.save(image_path) + return image_path + + _emit_text_plan_progress(progress, "novel_portraits_scene_start", "Generating scene character portraits") + scene_portrait_tasks = [] + sem = asyncio.Semaphore(3) + for character in characters_in_novel: + base_path = os.path.join(base_character_portrait_dir, f"character_{character.index}_{safe_path_component(character.identifier_in_novel)}.png") + for event_idx, identifier_in_event in character.active_events.items(): + event_characters = event_idx_to_characters_in_event[int(event_idx)] + character_in_event = [char for char in event_characters if char.identifier_in_event == identifier_in_event][0] + for scene_idx, identifier_in_scene in character_in_event.active_scenes.items(): + scene = event_idx_to_scenes[int(event_idx)][int(scene_idx)] + character_in_scene = [char for char in scene.characters if char.identifier_in_scene == identifier_in_scene][0] + scene_portrait_tasks.append(generate_scene_portrait(sem, base_path, character_in_scene, int(event_idx), int(scene_idx))) + if scene_portrait_tasks: + await asyncio.gather(*scene_portrait_tasks) + _emit_text_plan_progress(progress, "novel_portraits_done", "Scene character portraits ready") + + working_dir_scene_videos = os.path.join(self.working_dir, "videos") + os.makedirs(working_dir_scene_videos, exist_ok=True) + scene_video_dirs: list[str] = [] + for event in extracted_events: + for scene in event_idx_to_scenes[event.index]: + scene_video_dir = os.path.join(working_dir_scene_videos, f"event_{event.index}", f"scene_{scene.idx}") + os.makedirs(scene_video_dir, exist_ok=True) + self.script2video_pipeline.working_dir = scene_video_dir + character_portraits_registry = {} + for character in scene.characters: + character_portraits_registry[character.identifier_in_scene] = { + "portrait": { + "path": os.path.join(working_dir_character_portrait, f"event_{event.index}", f"scene_{scene.idx}", f"character_{character.idx}_{safe_path_component(character.identifier_in_scene)}.png"), + "description": f"A portrait of {character.identifier_in_scene}", + } + } + _emit_text_plan_progress(progress, "novel_scene_render_start", "Rendering novel scene video", {"event_idx": event.index, "scene_idx": scene.idx}) + await self.script2video_pipeline( + script=scene.script, + user_requirement="", + style=style or "realistic movie style", + characters=scene.characters, + character_portraits_registry=character_portraits_registry, + quiet=quiet, + progress=progress, + ) + scene_video_dirs.append(scene_video_dir) + _emit_text_plan_progress(progress, "novel_scene_render_done", "Rendered novel scene video", {"event_idx": event.index, "scene_idx": scene.idx, "path": scene_video_dir}) + + _emit_text_plan_progress(progress, "novel_render_completed", "Novel scene render complete", {"scene_count": len(scene_video_dirs)}) + return { + "character_portraits_dir": working_dir_character_portrait, + "scene_videos_dir": working_dir_scene_videos, + "scene_video_dirs": scene_video_dirs, + "scene_count": len(scene_video_dirs), + } + + async def __call__( + self, + novel_text: str, + style: str, + ): + print("🎬 Novel to Movie Pipeline Started".center(80, "=")) + + # Step 1: Compress the novel text + print() + print("📋 Step 1: Compress the novel text".center(80, "-")) + + working_dir_novel_compressor = os.path.join(self.working_dir, "novel") + os.makedirs(working_dir_novel_compressor, exist_ok=True) + with open(os.path.join(working_dir_novel_compressor, "novel.txt"), "w", encoding="utf-8") as f: + f.write(novel_text) + print(f"🗂️ Working directory: {working_dir_novel_compressor}") + + print("🔖 Splitting the novel into chunks...") + novel_chunks = self.novel_compressor.split(novel_text) + for idx, novel_chunk in enumerate(novel_chunks): + with open(os.path.join(working_dir_novel_compressor, f"novel_chunk_{idx}.txt"), "w", encoding="utf-8") as f: + f.write(novel_chunk) + print(f"🔖 Split the novel into {len(novel_chunks)} chunks, all saved to {working_dir_novel_compressor}.") + + + print() + print("🔖 Compressing the novel chunks...") + compressed_novel_chunks = [None] * len(novel_chunks) + index_chunk_pairs_unfinished = [] + for index, novel_chunk in enumerate(novel_chunks): + path = os.path.join(working_dir_novel_compressor, f"novel_chunk_{index}_compressed.txt") + if os.path.exists(path): + compressed_novel_chunks[index] = open(path, "r", encoding="utf-8").read() + print(f"⏭️ Skipping compression for chunk {index} as it already exists.") + else: + index_chunk_pairs_unfinished.append((index, novel_chunk)) + + sem = asyncio.Semaphore(5) + tasks = [ + self.novel_compressor.compress_single_novel_chunk(sem, index, novel_chunk) + for index, novel_chunk in index_chunk_pairs_unfinished + ] + task_outputs = await asyncio.gather(*tasks) + for index, novel_chunk_compressed in task_outputs: + save_path = os.path.join(working_dir_novel_compressor, f"novel_chunk_{index}_compressed.txt") + with open(save_path, "w", encoding="utf-8") as f: + f.write(novel_chunk_compressed) + print(f"✅ Compressed chunk {index}, saved to {save_path}") + compressed_novel_chunks[index] = novel_chunk_compressed + print("🔖 Compressed all novel chunks.") + + + print() + print("🔖 Merging the compressed novel chunks...") + path = os.path.join(working_dir_novel_compressor, "novel_compressed.txt") + if os.path.exists(path): + compressed_novel = open(path, "r", encoding="utf-8").read() + print(f"⏭️ Skipping merging as {path} already exists.") + else: + compressed_novel = self.novel_compressor.aggregate(compressed_novel_chunks) + with open(path, "w", encoding="utf-8") as f: + f.write(compressed_novel) + print(f"✅ Merged the compressed novel chunks, saved to {path}") + print(f"🔖 Merging completed.") + + # summary + print() + print("📌 Summary:") + print(f"📌 Before Compression: {len(novel_text)} characters") + print(f"📌 After Compression: {len(compressed_novel)} characters") + print(f"📌 Compression Ratio: {len(compressed_novel) / len(novel_text):.2%}") + + print("📋 Step 1: Compress the novel text".center(80, "-")) + + + # Step 2: Extract events from the compressed novel + print() + print("📋 Step 2: Extract events from the compressed novel".center(80, "-")) + working_dir_event_extractor = os.path.join(self.working_dir, "events") + os.makedirs(working_dir_event_extractor, exist_ok=True) + print(f"🗂️ Working directory: {working_dir_event_extractor}") + + extracted_events = [] + for event_json_fname in sorted(os.listdir(working_dir_event_extractor), key=lambda x: int(x.split('_')[1].split('.')[0])): + event_json_path = os.path.join(working_dir_event_extractor, event_json_fname) + if os.path.exists(event_json_path): + with open(event_json_path, "r", encoding="utf-8") as f: + event_data = json.load(f) + event: Event = Event.model_validate(event_data) + extracted_events.append(event) + + if len(extracted_events) > 0: + if extracted_events[-1].is_last: + print(f"⏭️ Skipping event extraction as all events already exist in {working_dir_event_extractor}.") + else: + print(f"🔖 Continuing event extraction from {len(extracted_events)} existing events...") + else: + print("🔖 Starting event extraction ...") + + while len(extracted_events) == 0 or not extracted_events[-1].is_last: + next_event = self.event_extractor.extract_next_event( + novel_text=compressed_novel, + extracted_events=extracted_events, + ) + event_json_path = os.path.join(working_dir_event_extractor, f"event_{len(extracted_events)}.json") + with open(event_json_path, "w", encoding="utf-8") as f: + json.dump(next_event.model_dump(), f, ensure_ascii=False, indent=4) + print(f"✅ Extracted event {next_event.index}, saved to {event_json_path}") + + extracted_events.append(next_event) + + # summary + print() + print("📌 Summary:") + print(f"📌 Extracted a total of {len(extracted_events)} events.") + + print("📋 Step 2: Extract events from the compressed novel".center(80, "-")) + + + # Step 3: Extract relevant chunks for each event + print() + print("📋 Step 3: Retrieve relevant chunks for each event".center(80, "-")) + working_dir_knowledge_base = os.path.join(self.working_dir, "knowledge_base") + working_dir_retrieve = os.path.join(self.working_dir, "relevant_chunks") + os.makedirs(working_dir_knowledge_base, exist_ok=True) + os.makedirs(working_dir_retrieve, exist_ok=True) + print(f"🗂️ Working directory: {working_dir_knowledge_base} and {working_dir_retrieve}") + + print("🔖 Constructing knowledge base from the raw novel text...") + embeddings = CacheBackedEmbeddings.from_bytes_store( + underlying_embeddings=self.embeddings, + document_embedding_cache=LocalFileStore( + root_path=working_dir_knowledge_base, + ), + namespace=self.embeddings.model, + key_encoder="sha256", + ) + novel_splitter = RecursiveCharacterTextSplitter( + chunk_size=512, + chunk_overlap=128, + ) + novel_chunks = novel_splitter.split_text(novel_text) + knowledge_base = FAISS.from_texts(texts=novel_chunks, embedding=embeddings) + print(f"🔖 Constructed knowledge base with {len(novel_chunks)} chunks, saved to {working_dir_knowledge_base}") + + + print("🔖 Retrieving relevant chunks for each event...") + async def retrieve_relevant_chunks(sem, knowledge_base, event): + async with sem: + relevant_chunk_score_dict = {} + for process in event.process_chain: + chunks = knowledge_base.similarity_search(process, k=10) + chunks = [chunk.page_content for chunk in chunks if chunk.page_content not in relevant_chunk_score_dict] + + chunk_score_pairs = await self.rerank_model( + documents=chunks, + query=process, + top_n=10, + ) + + threshold = 0.7 + for chunk, score in chunk_score_pairs: + if score >= threshold: + if chunk not in relevant_chunk_score_dict: + relevant_chunk_score_dict[chunk] = score + else: + relevant_chunk_score_dict[chunk] += score + + return event.index, relevant_chunk_score_dict + + event_idx_to_relevant_chunk_score_dict = {} + + sem = asyncio.Semaphore(10) + tasks = [] + for event in extracted_events: + chunks_dir = os.path.join(working_dir_retrieve, f"event_{event.index}") + if os.path.exists(chunks_dir) and len(os.listdir(chunks_dir)) > 0: + relevant_chunk_score_dict = {} + for chunk_fname in os.listdir(chunks_dir): + chunk_path = os.path.join(chunks_dir, chunk_fname) + score = float(chunk_fname.split('-score_')[1].split('.txt')[0]) + with open(chunk_path, "r", encoding="utf-8") as f: + chunk = f.read() + relevant_chunk_score_dict[chunk] = score + event_idx_to_relevant_chunk_score_dict[event.index] = relevant_chunk_score_dict + print(f"⏭️ Skipping retrieval for event {event.index} as it already exists.") + else: + tasks.append(retrieve_relevant_chunks(sem, knowledge_base, event)) + + if len(tasks) > 0: + for task in asyncio.as_completed(tasks): + event_index, relevant_chunk_score_dict = await task + chunks_dir = os.path.join(working_dir_retrieve, f"event_{event_index}") + os.makedirs(chunks_dir, exist_ok=True) + for idx, (chunk, score) in enumerate(relevant_chunk_score_dict.items()): + chunk_path = os.path.join(chunks_dir, f"chunk_{idx}-score_{score:.2f}.txt") + with open(chunk_path, "w", encoding="utf-8") as f: + f.write(chunk) + event_idx_to_relevant_chunk_score_dict[event_index] = relevant_chunk_score_dict + print(f"✅ Retrieved {len(relevant_chunk_score_dict)} relevant chunks for event {event_index}, saved to {chunks_dir}") + + print("🔖 Retrieved relevant chunks for all events.") + print("📋 Step 3: Retrieve relevant chunks for each event".center(80, "-")) + + + + # Step 4: Extract scenes for each event, design the script for each scene + print() + print("📋 Step 4: Extract scenes for each event, design the script for each scene".center(80, "-")) + working_dir_scene_extractor = os.path.join(self.working_dir, "scenes") + os.makedirs(working_dir_scene_extractor, exist_ok=True) + print(f"🗂️ Working directory: {working_dir_scene_extractor}") + + + unfinished_event_indices = [] + event_idx_to_scenes = {event.index: [] for event in extracted_events} + for event in extracted_events: + scenes_dir = os.path.join(working_dir_scene_extractor, f"event_{event.index}") + if os.path.exists(scenes_dir): + for scene_json_fname in sorted(os.listdir(scenes_dir), key=lambda x: int(x.split('_')[1].split('.')[0])): + scene_json_path = os.path.join(scenes_dir, scene_json_fname) + with open(scene_json_path, "r", encoding="utf-8") as f: + scene_data = json.load(f) + scene = Scene.model_validate(scene_data) + event_idx_to_scenes[event.index].append(scene) + + if len(event_idx_to_scenes[event.index]) > 0 and event_idx_to_scenes[event.index][-1].is_last: + print(f"⏭️ Skipping scene extraction for event {event.index} as all scenes already exist in {scenes_dir}.") + else: + unfinished_event_indices.append(event.index) + + if len(unfinished_event_indices) > 0: + if len(unfinished_event_indices) == len(extracted_events): + print(f"🔖 Starting scene extraction for all events...") + else: + print(f"🔖 Continuing scene extraction for events: {unfinished_event_indices}") + + + async def extract_scenes_for_event(sem, relevant_chunks, event, previous_scenes): + async with sem: + os.makedirs(os.path.join(working_dir_scene_extractor, f"event_{event.index}"), exist_ok=True) + + while len(previous_scenes) == 0 or not previous_scenes[-1].is_last: + next_scene = await self.scene_extractor.get_next_scene( + relevant_chunks=relevant_chunks, + event=event, + previous_scenes=previous_scenes, + ) + scene_json_path = os.path.join(working_dir_scene_extractor, f"event_{event.index}", f"scene_{len(previous_scenes)}.json") + with open(scene_json_path, "w", encoding="utf-8") as f: + json.dump(next_scene.model_dump(), f, ensure_ascii=False, indent=4) + print(f"✔️​ Extracted scene {next_scene.idx} for event {event.index}, saved to {scene_json_path}") + previous_scenes.append(next_scene) + + print(f"✅ Extracted all {len(previous_scenes)} scenes for event {event.index}.") + return event.index, previous_scenes + + + sem = asyncio.Semaphore(8) + for event_index in unfinished_event_indices: + relevant_chunks = list(event_idx_to_relevant_chunk_score_dict[event_index].keys()) + tasks.append(extract_scenes_for_event(sem, relevant_chunks, extracted_events[event_index], event_idx_to_scenes[event_index])) + + task_outputs = await asyncio.gather(*tasks) + for event_index, previous_scenes in task_outputs: + event_idx_to_scenes[event_index] = previous_scenes + + print("🔖 Extracted scenes for all events.") + print("📋 Step 4: Extract scenes for each event, design the script for each scene".center(80, "-")) + + + + # Step 5: Merge characters from scene-level to event-level, then to novel-level + print() + print("📋 Step 5: Merge characters from scene-level to novel-level".center(80, "-")) + working_dir_global_information_planner = os.path.join(self.working_dir, "global_information") + os.makedirs(working_dir_global_information_planner, exist_ok=True) + print(f"🗂️ Working directory: {working_dir_global_information_planner}") + + # Step 5.1: Merge characters from scene-level to event-level + print("🔖 Merging characters across scenes in each event...") + working_dir_characters = os.path.join(working_dir_global_information_planner, "characters") + os.makedirs(working_dir_characters, exist_ok=True) + + async def merge_characters_across_scenes_in_event(sem, event_idx, scenes): + async with sem: + merged_characters = await self.global_information_planner.merge_characters_across_scenes_in_event( + event_idx=event_idx, + scenes=scenes, + ) + path = os.path.join(working_dir_characters, "event_level", f"event_{event_idx}_characters.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump([char.model_dump() for char in merged_characters], f, ensure_ascii=False, indent=4) + print(f"✅ Merged characters for event {event_idx}, saved to {path}") + + return event_idx, merged_characters + + + event_idx_to_characters_in_event = {} + + sem = asyncio.Semaphore(8) + tasks = [] + for event in extracted_events: + path = os.path.join(working_dir_characters, "event_level", f"event_{event.index}_characters.json") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + character_data = json.load(f) + characters = [CharacterInEvent.model_validate(char) for char in character_data] + event_idx_to_characters_in_event[event.index] = characters + print(f"⏭️ Skipping character merging for event {event.index} as it already exists.") + else: + tasks.append(merge_characters_across_scenes_in_event(sem, event.index, event_idx_to_scenes[event.index])) + + task_outputs = await asyncio.gather(*tasks) + for event_index, merged_characters in task_outputs: + event_idx_to_characters_in_event[event_index] = merged_characters + + print("🔖 Merged characters across scenes in each event.") + + # Step 5.2: Merge characters from event-level to novel-level + print("🔖 Merging characters across events in the novel...") + + working_dir_characters_novel = os.path.join(working_dir_characters, f"novel_level") + os.makedirs(working_dir_characters_novel, exist_ok=True) + + fnames = os.listdir(working_dir_characters_novel) + existing_characters_in_novel = [] + if len(fnames) > 0: + fname = max(fnames, key=lambda x: int(x.split('_')[-1].split('.json')[0])) + start_event_idx = int(fname.split('_')[-1].split('.json')[0]) + 1 + path = os.path.join(working_dir_characters_novel, fname) + with open(path, "r", encoding="utf-8") as f: + character_data = json.load(f) + existing_characters_in_novel = [CharacterInNovel.model_validate(char) for char in character_data] + + if start_event_idx == len(extracted_events): + print(f"⏭️ Skipping merging as all events already merged to novel-level in {working_dir_characters_novel}.") + else: + print(f"🔖 Continuing merging from event {start_event_idx}, currently {len(existing_characters_in_novel)} characters in novel.") + + else: + existing_characters_in_novel = [] + start_event_idx = 0 + + for event in extracted_events[start_event_idx:]: + characters_in_event = event_idx_to_characters_in_event[event.index] + path = os.path.join(working_dir_characters_novel, f"novel_characters_after_event_{event.index}.json") + existing_characters_in_novel = self.global_information_planner.merge_characters_to_existing_characters_in_novel( + event_idx=event.index, + existing_characters_in_novel=existing_characters_in_novel, + characters_in_event=characters_in_event, + ) + with open(path, "w", encoding="utf-8") as f: + json.dump([char.model_dump() for char in existing_characters_in_novel], f, ensure_ascii=False, indent=4) + print(f"✅ Merged characters from event {event.index} to novel-level, now {len(existing_characters_in_novel)} characters in novel, saved to {path}") + + print("🔖 Merged characters across events in the novel.") + + characters_in_novel = existing_characters_in_novel + + print("📋 Step 5: Merge characters from scene-level to novel-level".center(80, "-")) + + + + + # Step 6: Generate the portrait for all characters in the novel + print() + print("📋 Step 6: Generate the reference images for all characters in the specific scene") + + working_dir_character_portrait = os.path.join(self.working_dir, "character_portraits") + os.makedirs(working_dir_character_portrait, exist_ok=True) + print(f"🗂️ Working directory: {working_dir_character_portrait}") + + print("🔖 Generating character portraits based on static features ...") + base_character_portrait_dir = os.path.join(working_dir_character_portrait, "base") + os.makedirs(base_character_portrait_dir, exist_ok=True) + + async def generate_portrait_for_character(sem, character: CharacterInNovel): + async with sem: + image_path = os.path.join(base_character_portrait_dir, f"character_{character.index}_{safe_path_component(character.identifier_in_novel)}.png") + + if os.path.exists(image_path): + print(f"⏭️ Skipping portrait generation for character {character.idx} as it already exists.") + return + + prompt = f"Generate a full-body, front-view portrait based on the following description, in the style of {style}:" + prompt += f"\nCharacter Identifier: {character.identifier_in_novel}" + prompt += f"\nFeatures: {character.static_features}" + prompt += f"\nThe character should be centered in the image, occupying most of the frame. Gazing straight ahead. Standing with arms relaxed at sides. Natural expression. The background should be plain white." + + image = await self.image_generator.generate_single_image( + prompt=prompt, + size="512x512", + ) + image.save(image_path) + print(f"✅ Generated portrait for character {character.index} ({character.identifier_in_novel}), saved to {image_path}") + + + sem = asyncio.Semaphore(5) + tasks = [ + generate_portrait_for_character(sem, character) + for character in characters_in_novel + ] + + await asyncio.gather(*tasks) + print("🔖 Generated character portraits based on static features.") + + + print("🔖 Generating character portraits based on dynamic features in the specific scene") + + async def generate_portrait_for_character_in_scene( + sem, + base_character_image_path: str, + character: CharacterInScene, + event_idx: int, + scene_idx: int, + ): + async with sem: + image_path = os.path.join( + working_dir_character_portrait, + f"event_{event_idx}", + f"scene_{scene_idx}", + f"character_{character.idx}_{character.identifier_in_scene}.png", + ) + os.makedirs(os.path.dirname(image_path), exist_ok=True) + + if os.path.exists(image_path): + print(f"⏭️ Skipping portrait generation for event {event_idx}, scene {scene_idx}, character {character.idx} as it already exists.") + return + + if not character.is_visible: + shutil.copy(base_character_image_path, image_path) + print(f"⏭️ For event {event_idx}, scene {scene_idx}, character {character.idx} ({character.identifier_in_scene}) is not visible, copied base portrait to {image_path}") + return + + if character.dynamic_features is None: + shutil.copy(base_character_image_path, image_path) + print(f"⏭️ For event {event_idx}, scene {scene_idx}, character {character.idx} ({character.identifier_in_scene}) has no dynamic features, copied base portrait to {image_path}") + return + + prompt = f"Generate a full-body, front-view portrait based on the provided base image. Modify the base image according to the following dynamic features, in the style of {style}. Keep the character's identity consistent with the base image:" + prompt += f"\nCharacter Identifier: {character.identifier_in_scene}" + prompt += f"\nDynamic Features: {character.dynamic_features}" + prompt += f"\nThe character should be centered in the image, occupying most of the frame. Gazing straight ahead. Standing with arms relaxed at sides. Natural expression. The background should be plain white." + + prompt = await self.rewriter(prompt) + + + image = await self.image_generator.generate_single_image( + prompt=prompt, + reference_image_paths=[base_character_image_path], + size="512x512", + ) + image.save(image_path) + print(f"✅ For event {event_idx}, scene {scene_idx}, generated portrait for character {character.idx} ({character.identifier_in_scene}), saved to {image_path}") + + + sem = asyncio.Semaphore(3) + tasks = [] + for character in characters_in_novel: + character_base_image_path = os.path.join(base_character_portrait_dir, f"character_{character.index}_{safe_path_component(character.identifier_in_novel)}.png") + for event_idx, identifier_in_event in character.active_events.items(): + characters_in_event: List[CharacterInEvent] = event_idx_to_characters_in_event[event_idx] + character_in_event = [char for char in characters_in_event if char.identifier_in_event == identifier_in_event][0] # TODO: 这里的数据结构没有做好,居然还要遍历查找。。。 + for scene_idx, identifier_in_scene in character_in_event.active_scenes.items(): + scene = event_idx_to_scenes[event_idx][scene_idx] + character_in_scene: CharacterInScene = [char for char in scene.characters if char.identifier_in_scene == identifier_in_scene][0] # TODO: 这里的数据结构也没有做好 + tasks.append( + generate_portrait_for_character_in_scene( + sem, + character_base_image_path, + character_in_scene, + event_idx, + scene_idx, + ) + ) + await asyncio.gather(*tasks) + print("🔖 Generated character portraits based on dynamic features in the specific scene") + + print("📋 Step 6: Generate the reference images for all characters in the specific scene".center(80, "-")) + + + + # Step 7: Generate video for each scene + print("📋 Step 7: Generate the video for each scene".center(80, "-")) + working_dir_scene_videos = os.path.join(self.working_dir, "videos") + os.makedirs(working_dir_scene_videos, exist_ok=True) + + for event in extracted_events: + scenes: List[Scene] = event_idx_to_scenes[event.index] + for scene in scenes: + scene_video_dir = os.path.join(working_dir_scene_videos, f"event_{event.index}", f"scene_{scene.idx}") + os.makedirs(scene_video_dir, exist_ok=True) + + self.script2video_pipeline.working_dir = scene_video_dir + script = scene.script + style = "realistic movie style" + character_registry = {} + for character in scene.characters: + character_registry[character.identifier_in_scene] = [ + { + "path": os.path.join( + working_dir_character_portrait, + f"event_{event.index}", + f"scene_{scene.idx}", + f"character_{character.idx}_{character.identifier_in_scene}.png", + ), + "description": f"A portrait of {character.identifier_in_scene}", + } + ] + await self.script2video_pipeline( + script=script, + style=style, + character_registry=character_registry + ) + print(f"✅ Generated video for event {event.index}, scene {scene.idx}, saved to {scene_video_dir}") + print("📋 Step 7: Generate the video for each scene".center(80, "-")) + + +# is_last flags are asserted by the LLM only; cap the extraction loops so a +# model that never sets one cannot spend tokens forever. +MAX_EXTRACTED_EVENTS = 50 +MAX_SCENES_PER_EVENT = 30 + + +def _ensure_extraction_cap(count: int, cap: int, what: str) -> None: + if count >= cap: + raise RuntimeError( + f"Extraction reached {count} {what} without an is_last marker (cap: {cap}); " + "aborting to avoid unbounded LLM calls." + ) diff --git a/pipelines/script2video_pipeline.py b/pipelines/script2video_pipeline.py new file mode 100644 index 0000000..cc8ce85 --- /dev/null +++ b/pipelines/script2video_pipeline.py @@ -0,0 +1,822 @@ +import os +import shutil +import json +import logging +import asyncio +import time +from typing import Any, Callable, Optional, Dict, List, Tuple, Literal, Type, TypeVar +from moviepy import VideoFileClip, concatenate_videoclips +from PIL import Image +from agents import * +import yaml +from interfaces import * +from langchain.chat_models import init_chat_model +from tools.render_backend import RenderBackend +from utils.provider_presets import resolve_chat_model_config + + + + +TModel = TypeVar("TModel") + + +def _normalize_model_list(items: Any, model_cls: Type[TModel], field_name: str) -> List[TModel]: + if items is None: + return [] + if not isinstance(items, list): + raise TypeError(f"{field_name} must be a list, got {type(items).__name__}") + normalized: List[TModel] = [] + for idx, item in enumerate(items): + if isinstance(item, model_cls): + normalized.append(item) + elif isinstance(item, dict): + normalized.append(model_cls.model_validate(item)) + else: + raise TypeError(f"{field_name}[{idx}] must be {model_cls.__name__} or dict, got {type(item).__name__}") + return normalized + + +def _group_shots_into_cameras(shot_descriptions: List[ShotDescription]) -> List[Camera]: + cameras_by_idx: Dict[int, Camera] = {} + for shot_description in shot_descriptions: + camera = cameras_by_idx.get(shot_description.cam_idx) + if camera is None: + camera = Camera(idx=shot_description.cam_idx, active_shot_idxs=[]) + cameras_by_idx[shot_description.cam_idx] = camera + camera.active_shot_idxs.append(shot_description.idx) + return list(cameras_by_idx.values()) + +def _collect_priority_shot_idxs(camera_tree: List[Camera]) -> List[int]: + """Shot indices that other cameras depend on.""" + return [camera.parent_shot_idx for camera in camera_tree if camera.parent_shot_idx is not None] + + +def _pipeline_print(quiet: bool, message: str) -> None: + if not quiet: + print(message) + + +def _emit_text_plan_progress(progress, stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None: + if progress is not None: + progress(stage, message, metadata or {}) + + +def _emit_render_progress(progress, stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None: + if progress is not None: + progress(stage, message, metadata or {}) + + +def _scoped_progress(progress, **scope): + if progress is None: + return None + + def emit(stage: str, message: str, metadata: Dict[str, Any] | None = None) -> None: + payload = dict(scope) + payload.update(metadata or {}) + _emit_render_progress(progress, stage, message, payload) + + return emit + + +class Script2VideoPipeline: + + def __init__( + self, + chat_model: str, + image_generator, + video_generator, + working_dir: str, + ): + + self.chat_model = chat_model + self.image_generator = image_generator + self.video_generator = video_generator + + self.character_extractor = CharacterExtractor(chat_model=self.chat_model) + self.character_portraits_generator = CharacterPortraitsGenerator(image_generator=self.image_generator) + self.storyboard_artist = StoryboardArtist(chat_model=self.chat_model) + self.camera_image_generator = CameraImageGenerator(chat_model=self.chat_model, image_generator=self.image_generator, video_generator=self.video_generator) + self.reference_image_selector = ReferenceImageSelector(chat_model=self.chat_model) + + self.working_dir = working_dir + os.makedirs(self.working_dir, exist_ok=True) + self.character_portrait_events = {} + self.shot_desc_events = {} + self.frame_events = {} + + + async def plan_text_artifacts( + self, + script: str, + user_requirement: str, + style: str, + characters: List[CharacterInScene] = None, + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + quiet: bool = False, + ): + """Generate only structured text artifacts required before rendering. + + This helper intentionally stops before character portraits, frame generation, + video generation, and final concatenation so an agent loop can pause for + user review after narrative planning. + """ + self.character_portrait_events = {} + self.shot_desc_events = {} + self.frame_events = {} + + if characters is None: + _emit_text_plan_progress(progress, "extract_characters", "Extracting characters from script") + characters = await self.extract_characters(script=script, quiet=quiet) + else: + characters = _normalize_model_list(characters, CharacterInScene, "characters") + _emit_text_plan_progress(progress, "extract_characters", "Using provided characters", {"provided": True, "count": len(characters)}) + characters_path = os.path.join(self.working_dir, "characters.json") + if not os.path.exists(characters_path): + with open(characters_path, "w", encoding="utf-8") as f: + json.dump([character.model_dump() for character in characters], f, ensure_ascii=False, indent=4) + for character in characters: + self.character_portrait_events[character.idx] = asyncio.Event() + + _emit_text_plan_progress(progress, "design_storyboard", "Designing storyboard") + storyboard = await self.design_storyboard( + script=script, + characters=characters, + user_requirement=user_requirement, + quiet=quiet, + ) + _emit_text_plan_progress(progress, "decompose_shots", "Decomposing shot visual descriptions", {"shot_count": len(storyboard)}) + shot_descriptions = await self.decompose_visual_descriptions( + shot_brief_descriptions=storyboard, + characters=characters, + quiet=quiet, + ) + camera_tree = None + for attempt in range(2): + try: + stage = "construct_camera_tree" if attempt == 0 else "construct_camera_tree_retry" + message = "Constructing camera tree" if attempt == 0 else "Retrying camera tree construction after schema/type failure" + _emit_text_plan_progress(progress, stage, message, {"shot_count": len(shot_descriptions), "attempt": attempt + 1}) + camera_tree = await self.construct_camera_tree( + shot_descriptions=shot_descriptions, + quiet=quiet, + ) + break + except Exception: + camera_tree_path = os.path.join(self.working_dir, "camera_tree.json") + if os.path.exists(camera_tree_path): + os.remove(camera_tree_path) + if attempt == 1: + raise + assert camera_tree is not None + return { + "characters": characters, + "storyboard": storyboard, + "shot_descriptions": shot_descriptions, + "camera_tree": camera_tree, + } + + + @classmethod + def init_from_config(cls, config_path: str): + with open(config_path, "r") as f: + config = yaml.safe_load(f) + + chat_model_args = resolve_chat_model_config(config["chat_model"]["init_args"]) + chat_model = init_chat_model(**chat_model_args) + backend = RenderBackend.from_config(config) + + return cls( + chat_model=chat_model, + image_generator=backend.image_generator, + video_generator=backend.video_generator, + working_dir=config["working_dir"], + ) + + async def __call__( + self, + script: str, + user_requirement: str, + style: str, + characters: List[CharacterInScene] = None, + character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]] = None, + quiet: bool = False, + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + ): + _emit_render_progress(progress, "render_start", "Starting script2video render") + if characters is None: + _emit_render_progress(progress, "extract_characters", "Extracting characters before render") + characters = await self.extract_characters(script=script, quiet=quiet) + + # characters_path = os.path.join(self.working_dir, "characters.json") + # if os.path.exists(characters_path): + # with open(characters_path, "r", encoding="utf-8") as f: + # characters = [CharacterInScene.model_validate(c) for c in json.load(f)] + # print(f"🚀 Loaded {len(characters)} characters from existing file.") + # else: + # print(f"🔍 Extracting characters from script...") + # characters = await self.extract_characters(script=script) + # with open(characters_path, "w", encoding="utf-8") as f: + # json.dump([c.model_dump() for c in characters], f, ensure_ascii=False, indent=4) + # print(f"☑️ Extracted {len(characters)} characters from script and saved to {characters_path}.") + else: + characters = _normalize_model_list(characters, CharacterInScene, "characters") + _emit_render_progress(progress, "extract_characters", "Using provided characters for render", {"provided": True, "count": len(characters)}) + for character in characters: + self.character_portrait_events[character.idx] = asyncio.Event() + + if character_portraits_registry is None: + character_portraits_registry_path = os.path.join(self.working_dir, "character_portraits_registry.json") + if os.path.exists(character_portraits_registry_path): + with open(character_portraits_registry_path, "r", encoding="utf-8") as f: + character_portraits_registry = json.load(f) + print(f"🚀 Loaded {len(character_portraits_registry)} character portraits from existing file.") + _emit_render_progress(progress, "character_portraits_loaded", "Loaded existing character portraits", {"count": len(character_portraits_registry)}) + else: + print(f"🔍 Generating character portraits...") + _emit_render_progress(progress, "character_portraits_start", "Generating character portraits", {"character_count": len(characters)}) + character_portraits_registry = await self.generate_character_portraits( + characters=characters, + character_portraits_registry=None, + style=style, + progress=progress, + ) + + with open(character_portraits_registry_path, "w", encoding="utf-8") as f: + json.dump(character_portraits_registry, f, ensure_ascii=False, indent=4) + print(f"☑️ Generated {len(character_portraits_registry)} character portraits and saved to {character_portraits_registry_path}.") + _emit_render_progress(progress, "character_portraits_done", "Character portraits ready", {"count": len(character_portraits_registry)}) + + + + # design shots + _emit_render_progress(progress, "load_storyboard", "Loading or designing storyboard") + storyboard = await self.design_storyboard( + script=script, + characters=characters, + user_requirement=user_requirement, + quiet=quiet, + ) + _emit_render_progress(progress, "storyboard_ready", "Storyboard ready", {"shot_count": len(storyboard)}) + + # decompose visual descriptions of shots + _emit_render_progress(progress, "load_shot_descriptions", "Loading or decomposing shot descriptions", {"shot_count": len(storyboard)}) + shot_descriptions = await self.decompose_visual_descriptions( + shot_brief_descriptions=storyboard, + characters=characters, + quiet=quiet, + ) + _emit_render_progress(progress, "shot_descriptions_ready", "Shot descriptions ready", {"shot_count": len(shot_descriptions)}) + + # construct camera tree + _emit_render_progress(progress, "load_camera_tree", "Loading or constructing camera tree", {"shot_count": len(shot_descriptions)}) + camera_tree = await self.construct_camera_tree( + shot_descriptions=shot_descriptions, + quiet=quiet, + ) + _emit_render_progress(progress, "camera_tree_ready", "Camera tree ready", {"camera_count": len(camera_tree)}) + + priority_shot_idxs = [camera.parent_cam_idx for camera in camera_tree if camera.parent_cam_idx is not None] + _emit_render_progress(progress, "frames_start", "Generating frames for cameras", {"camera_count": len(camera_tree), "shot_count": len(shot_descriptions)}) + tasks = [ + self.generate_frames_for_single_camera( + camera=camera, + shot_descriptions=shot_descriptions, + characters=characters, + character_portraits_registry=character_portraits_registry, + priority_shot_idxs=priority_shot_idxs, + progress=progress, + ) + for camera in camera_tree + ] + + _emit_render_progress(progress, "video_clips_start", "Generating video clips for shots", {"shot_count": len(shot_descriptions)}) + video_tasks = [ + self.generate_video_for_single_shot( + shot_description=shot_description, + progress=progress, + ) + for shot_description in shot_descriptions + ] + tasks.extend(video_tasks) + await asyncio.gather(*tasks) + + final_video_path = os.path.join(self.working_dir, "final_video.mp4") + if os.path.exists(final_video_path): + print(f"🚀 Skipped concatenating videos, already exists.") + _emit_render_progress(progress, "final_video_exists", "Final video already exists", {"path": final_video_path}) + else: + print(f"🎬 Starting concatenating videos...") + _emit_render_progress(progress, "concat_start", "Concatenating video clips", {"shot_count": len(shot_descriptions)}) + video_clips = [ + VideoFileClip(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "video.mp4")) + for shot_description in shot_descriptions + ] + final_video = concatenate_videoclips(video_clips) + final_video.write_videofile(final_video_path, codec="libx264", preset="medium") + print(f"☑️ Concatenated videos, saved to {final_video_path}.") + _emit_render_progress(progress, "concat_done", "Final video concatenated", {"path": final_video_path}) + + _emit_render_progress(progress, "render_done", "Script2video render complete", {"final_video_path": final_video_path}) + return final_video_path + + + async def generate_frames_for_single_camera( + self, + camera: Camera, + shot_descriptions: List[ShotDescription], + characters: List[CharacterInScene], + character_portraits_registry: Dict[str, Dict[str, Dict[str, str]]], + priority_shot_idxs: List[int], + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + ): + # 1. generate the first_frame of the first shot of the camera + first_shot_idx = camera.active_shot_idxs[0] + first_shot_ff_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", "first_frame.png") + _emit_render_progress(progress, "camera_frames_start", f"Generating frames for camera {camera.idx}", {"camera_idx": camera.idx, "active_shot_idxs": camera.active_shot_idxs}) + + if os.path.exists(first_shot_ff_path): + print(f"🚀 Skipped generating first_frame for shot {first_shot_idx}, already exists.") + self.frame_events[first_shot_idx]["first_frame"].set() + _emit_render_progress(progress, "frame_exists", f"First frame for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": first_shot_ff_path}) + + else: + print(f"🖼️ Starting first_frame generation for shot {first_shot_idx}...") + _emit_render_progress(progress, "frame_start", f"Generating first frame for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame"}) + available_image_path_and_text_pairs = [] + + for character_idx in shot_descriptions[first_shot_idx].ff_vis_char_idxs: + identifier_in_scene = characters[character_idx].identifier_in_scene + registry_item = character_portraits_registry[identifier_in_scene] + for view, item in registry_item.items(): + available_image_path_and_text_pairs.append((item["path"], item["description"])) + + # generate the first_frame based on the shot_description.ff_desc + if camera.parent_shot_idx is not None: + # generate the first_frame based on the transition video + parent_shot_idx = camera.parent_shot_idx + await self.frame_events[parent_shot_idx]["first_frame"].wait() + parent_shot_ff_path = os.path.join(self.working_dir, "shots", f"{parent_shot_idx}", "first_frame.png") + transition_video_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", f"transition_video_from_shot_{parent_shot_idx}.mp4") + + if os.path.exists(transition_video_path): + print(f"🚀 Skipped generating transition video for shot {first_shot_idx} from shot {parent_shot_idx}, already exists.") + _emit_render_progress(progress, "transition_video_exists", f"Transition video for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "parent_shot_idx": parent_shot_idx, "path": transition_video_path}) + else: + print(f"🖼️ Starting transition video generation for shot {first_shot_idx} from shot {parent_shot_idx}...") + _emit_render_progress(progress, "transition_video_start", f"Generating transition video for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "parent_shot_idx": parent_shot_idx}) + transition_video_output = await self.camera_image_generator.generate_transition_video( + first_shot_visual_desc=shot_descriptions[parent_shot_idx].visual_desc, + second_shot_visual_desc=shot_descriptions[first_shot_idx].visual_desc, + first_shot_ff_path=parent_shot_ff_path, + progress=_scoped_progress(progress, camera_idx=camera.idx, shot_idx=first_shot_idx, parent_shot_idx=parent_shot_idx, artifact="transition_video"), + ) + transition_video_output.save(transition_video_path) + print(f"☑️ Generated transition video for shot {first_shot_idx} from shot {parent_shot_idx}, saved to {transition_video_path}.") + _emit_render_progress(progress, "transition_video_done", f"Transition video for shot {first_shot_idx} generated", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "parent_shot_idx": parent_shot_idx, "path": transition_video_path}) + + new_camera_image_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", f"new_camera_{camera.idx}.png") + if os.path.exists(new_camera_image_path): + print(f"🚀 Skipped generating new camera image for shot {first_shot_idx}, already exists.") + _emit_render_progress(progress, "new_camera_image_exists", f"New camera image for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "path": new_camera_image_path}) + else: + print(f"🖼️ Starting new camera image generation for shot {first_shot_idx}...") + _emit_render_progress(progress, "new_camera_image_start", f"Extracting new camera image for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx}) + new_camera_image = self.camera_image_generator.get_new_camera_image(transition_video_path) + new_camera_image.save(new_camera_image_path) + print(f"☑️ Generated new camera image for shot {first_shot_idx} (not completed), saved to {new_camera_image_path}.") + _emit_render_progress(progress, "new_camera_image_done", f"New camera image for shot {first_shot_idx} extracted", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "path": new_camera_image_path}) + + available_image_path_and_text_pairs.append( + ( + new_camera_image_path, + f"The composition and background are correct but some elements may be wrong. The wrong elements should be replaced.\nWrong elements: {camera.missing_info}.\nYou must select this image as the main reference and replace the characters in the image with the provided character portraits. Don't change the background." + ) + ) + + + # 如果子镜头缺少信息,则需要选择参考图像生成 + if camera.parent_shot_idx is None or camera.missing_info is not None: + ff_selector_output_path = os.path.join(self.working_dir, "shots", f"{first_shot_idx}", "first_frame_selector_output.json") + if os.path.exists(ff_selector_output_path): + with open(ff_selector_output_path, 'r', encoding='utf-8') as f: + ff_selector_output = json.load(f) + print(f"🚀 Loaded existing reference image selection and prompt for first_frame of shot {first_shot_idx} from {ff_selector_output_path}.") + _emit_render_progress(progress, "frame_prompt_exists", f"First frame prompt for shot {first_shot_idx} already exists", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": ff_selector_output_path}) + else: + print(f"🔍 Selecting reference images and generating prompt for first_frame of shot {first_shot_idx}...") + _emit_render_progress(progress, "frame_prompt_start", f"Selecting references for first frame of shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame"}) + ff_selector_output = await self.reference_image_selector.select_reference_images_and_generate_prompt( + available_image_path_and_text_pairs=available_image_path_and_text_pairs, + frame_description=shot_descriptions[first_shot_idx].ff_desc + ) + with open(ff_selector_output_path, 'w', encoding='utf-8') as f: + json.dump(ff_selector_output, f, ensure_ascii=False, indent=4) + + print(f"☑️ Selected reference images and generated prompt for first_frame of shot {first_shot_idx}, saved to {ff_selector_output_path}.") + _emit_render_progress(progress, "frame_prompt_done", f"Selected references for first frame of shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": ff_selector_output_path}) + + reference_image_path_and_text_pairs, prompt = ff_selector_output["reference_image_path_and_text_pairs"], ff_selector_output["text_prompt"] + prefix_prompt = "" + for i, (image_path, text) in enumerate(reference_image_path_and_text_pairs): + prefix_prompt += f"Image {i}: {text}\n" + prompt = f"{prefix_prompt}\n{prompt}" + reference_image_paths = [item[0] for item in reference_image_path_and_text_pairs] + ff_image: ImageOutput = await self.image_generator.generate_single_image( + prompt=prompt, + reference_image_paths=reference_image_paths, + size="1600x900", + ) + ff_image.save(first_shot_ff_path) + self.frame_events[first_shot_idx]["first_frame"].set() + print(f"☑️ Generated first_frame for shot {first_shot_idx}, saved to {first_shot_ff_path}.") + _emit_render_progress(progress, "frame_done", f"Generated first frame for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": first_shot_ff_path}) + else: + shutil.copy(new_camera_image_path, first_shot_ff_path) + self.frame_events[first_shot_idx]["first_frame"].set() + print(f"☑️ Generated first_frame for shot {first_shot_idx}, saved to {first_shot_ff_path}.") + _emit_render_progress(progress, "frame_done", f"Generated first frame for shot {first_shot_idx}", {"camera_idx": camera.idx, "shot_idx": first_shot_idx, "frame_type": "first_frame", "path": first_shot_ff_path}) + + + # 2. generate the following frames of the camera + priority_tasks = [] + normal_tasks = [] + + if shot_descriptions[first_shot_idx].variation_type in ["medium", "large"]: + task = self.generate_frame_for_single_shot( + shot_idx=first_shot_idx, + frame_type="last_frame", + first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc), + frame_desc=shot_descriptions[first_shot_idx].lf_desc, + visible_characters=[characters[idx] for idx in shot_descriptions[first_shot_idx].lf_vis_char_idxs], + character_portraits_registry=character_portraits_registry, + progress=progress, + ) + normal_tasks.append(task) + + for shot_idx in camera.active_shot_idxs[1:]: + first_frame_task = self.generate_frame_for_single_shot( + shot_idx=shot_idx, + frame_type="first_frame", + first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc), + frame_desc=shot_descriptions[shot_idx].ff_desc, + visible_characters=[characters[idx] for idx in shot_descriptions[shot_idx].ff_vis_char_idxs], + character_portraits_registry=character_portraits_registry, + progress=progress, + ) + if shot_idx in priority_shot_idxs: + priority_tasks.append(first_frame_task) + else: + normal_tasks.append(first_frame_task) + + + if shot_descriptions[shot_idx].variation_type in ["medium", "large"]: + last_frame_task = self.generate_frame_for_single_shot( + shot_idx=shot_idx, + frame_type="last_frame", + first_shot_ff_path_and_text_pair=(first_shot_ff_path, shot_descriptions[first_shot_idx].ff_desc), + frame_desc=shot_descriptions[shot_idx].lf_desc, + visible_characters=[characters[idx] for idx in shot_descriptions[shot_idx].lf_vis_char_idxs], + character_portraits_registry=character_portraits_registry, + progress=progress, + ) + normal_tasks.append(last_frame_task) + + + await asyncio.gather(*priority_tasks) + await asyncio.gather(*normal_tasks) + _emit_render_progress(progress, "camera_frames_done", f"Frames for camera {camera.idx} ready", {"camera_idx": camera.idx, "active_shot_idxs": camera.active_shot_idxs}) + + + + async def generate_video_for_single_shot( + self, + shot_description: ShotDescription, + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + ): + video_path = os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "video.mp4") + if os.path.exists(video_path): + print(f"🚀 Skipped generating video for shot {shot_description.idx}, already exists.") + _emit_render_progress(progress, "video_clip_exists", f"Video clip for shot {shot_description.idx} already exists", {"shot_idx": shot_description.idx, "path": video_path}) + else: + _emit_render_progress(progress, "video_clip_waiting_for_frames", f"Waiting for frames before video clip {shot_description.idx}", {"shot_idx": shot_description.idx}) + await self.frame_events[shot_description.idx]["first_frame"].wait() + if shot_description.variation_type in ["medium", "large"]: + await self.frame_events[shot_description.idx]["last_frame"].wait() + + frame_paths = [] + frame_paths.append(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "first_frame.png")) + if shot_description.variation_type in ["medium", "large"]: + frame_paths.append(os.path.join(self.working_dir, "shots", f"{shot_description.idx}", "last_frame.png")) + + print(f"🎬 Starting video generation for shot {shot_description.idx}...") + _emit_render_progress(progress, "video_clip_start", f"Generating video clip for shot {shot_description.idx}", {"shot_idx": shot_description.idx, "frame_count": len(frame_paths)}) + video_output = await self.video_generator.generate_single_video( + prompt=shot_description.motion_desc + "\n" + shot_description.audio_desc, + reference_image_paths=frame_paths, + progress=_scoped_progress(progress, shot_idx=shot_description.idx, artifact="video_clip"), + ) + video_output.save(video_path) + print(f"☑️ Generated video for shot {shot_description.idx}, saved to {video_path}.") + _emit_render_progress(progress, "video_clip_done", f"Generated video clip for shot {shot_description.idx}", {"shot_idx": shot_description.idx, "path": video_path}) + + async def generate_frame_for_single_shot( + self, + shot_idx: int, + frame_type: Literal["first_frame", "last_frame"], + first_shot_ff_path_and_text_pair: Tuple[str, str], + frame_desc: str, + visible_characters: List[CharacterInScene], + character_portraits_registry: Dict[str, Dict[str, Dict[str, str]]], + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + ) -> ImageOutput: + + frame_image_path = os.path.join(self.working_dir, "shots", f"{shot_idx}", f"{frame_type}.png") + + if os.path.exists(frame_image_path): + print(f"🚀 Skipped generating {frame_type} for shot {shot_idx}, already exists.") + _emit_render_progress(progress, "frame_exists", f"{frame_type} for shot {shot_idx} already exists", {"shot_idx": shot_idx, "frame_type": frame_type, "path": frame_image_path}) + + else: + print(f"🖼️ Starting {frame_type} generation for shot {shot_idx}...") + _emit_render_progress(progress, "frame_start", f"Generating {frame_type} for shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type}) + available_image_path_and_text_pairs = [] + for visible_character in visible_characters: + identifier_in_scene = visible_character.identifier_in_scene + registry_item = character_portraits_registry[identifier_in_scene] + for view, item in registry_item.items(): + available_image_path_and_text_pairs.append((item["path"], item["description"])) + + available_image_path_and_text_pairs.append(first_shot_ff_path_and_text_pair) + + selector_output_path = os.path.join(self.working_dir, "shots", f"{shot_idx}", f"{frame_type}_selector_output.json") + if os.path.exists(selector_output_path): + with open(selector_output_path, 'r', encoding='utf-8') as f: + selector_output = json.load(f) + print(f"🚀 Loaded existing reference image selection and prompt for {frame_type} frame of shot {shot_idx} from {selector_output_path}.") + _emit_render_progress(progress, "frame_prompt_exists", f"Prompt for {frame_type} of shot {shot_idx} already exists", {"shot_idx": shot_idx, "frame_type": frame_type, "path": selector_output_path}) + else: + print(f"🔍 Selecting reference images and generating prompt for {frame_type} frame of shot {shot_idx}...") + _emit_render_progress(progress, "frame_prompt_start", f"Selecting references for {frame_type} of shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type}) + selector_output = await self.reference_image_selector.select_reference_images_and_generate_prompt( + available_image_path_and_text_pairs=available_image_path_and_text_pairs, + frame_description=frame_desc + ) + with open(selector_output_path, 'w', encoding='utf-8') as f: + json.dump(selector_output, f, ensure_ascii=False, indent=4) + print(f"☑️ Selected reference images and generated prompt for {frame_type} frame of shot {shot_idx}, saved to {selector_output_path}.") + _emit_render_progress(progress, "frame_prompt_done", f"Selected references for {frame_type} of shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type, "path": selector_output_path}) + + reference_image_path_and_text_pairs, prompt = selector_output["reference_image_path_and_text_pairs"], selector_output["text_prompt"] + prefix_prompt = "" + for i, (image_path, text) in enumerate(reference_image_path_and_text_pairs): + prefix_prompt += f"Image {i}: {text}\n" + prompt = f"{prefix_prompt}\n{prompt}" + reference_image_paths = [item[0] for item in reference_image_path_and_text_pairs] + + frame_image: ImageOutput = await self.image_generator.generate_single_image( + prompt=prompt, + reference_image_paths=reference_image_paths, + size="1600x900", + ) + frame_image.save(frame_image_path) + print(f"☑️ Generated {frame_type} frame for shot {shot_idx}, saved to {frame_image_path}.") + _emit_render_progress(progress, "frame_done", f"Generated {frame_type} for shot {shot_idx}", {"shot_idx": shot_idx, "frame_type": frame_type, "path": frame_image_path}) + + + self.frame_events[shot_idx][frame_type].set() + return frame_image_path + + + async def construct_camera_tree( + self, + shot_descriptions: List[ShotDescription], + quiet: bool = False, + ): + camera_tree_path = os.path.join(self.working_dir, "camera_tree.json") + + if os.path.exists(camera_tree_path): + with open(camera_tree_path, "r", encoding="utf-8") as f: + camera_tree = json.load(f) + camera_tree = [Camera.model_validate(camera) for camera in camera_tree] + _pipeline_print(quiet, f"🚀 Loaded {len(camera_tree)} cameras from existing file.") + return camera_tree + + shot_descriptions = _normalize_model_list(shot_descriptions, ShotDescription, "shot_descriptions") + cameras = _group_shots_into_cameras(shot_descriptions) + + camera_tree = await self.camera_image_generator.construct_camera_tree(cameras=cameras, shot_descs=shot_descriptions) + camera_tree = _normalize_model_list(camera_tree, Camera, "camera_tree") + with open(camera_tree_path, "w", encoding="utf-8") as f: + json.dump([camera.model_dump() for camera in camera_tree], f, ensure_ascii=False, indent=4) + _pipeline_print(quiet, f"✅ Constructed camera tree and saved to {camera_tree_path}.") + return camera_tree + + + + + async def extract_characters( + self, + script: str, + quiet: bool = False, + ): + save_path = os.path.join(self.working_dir, "characters.json") + + if os.path.exists(save_path): + with open(save_path, "r", encoding="utf-8") as f: + characters = json.load(f) + characters = [CharacterInScene.model_validate(character) for character in characters] + _pipeline_print(quiet, f"🚀 Loaded {len(characters)} characters from existing file.") + else: + characters = await self.character_extractor.extract_characters(script) + with open(save_path, "w", encoding="utf-8") as f: + json.dump([character.model_dump() for character in characters], f, ensure_ascii=False, indent=4) + _pipeline_print(quiet, f"✅ Extracted {len(characters)} characters from script and saved to {save_path}.") + + for character in characters: + self.character_portrait_events[character.idx] = asyncio.Event() + + return characters + + + async def generate_character_portraits( + self, + characters: List[CharacterInScene], + character_portraits_registry: Optional[Dict[str, Dict[str, Dict[str, str]]]], + style: str, + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + ): + character_portraits_registry_path = os.path.join(self.working_dir, "character_portraits_registry.json") + if character_portraits_registry is None: + if os.path.exists(character_portraits_registry_path): + with open(character_portraits_registry_path, 'r', encoding='utf-8') as f: + character_portraits_registry = json.load(f) + else: + character_portraits_registry = {} + + + tasks = [ + self.generate_portraits_for_single_character(character, style, progress=progress) + for character in characters + if character.identifier_in_scene not in character_portraits_registry + ] + if tasks: + for future in asyncio.as_completed(tasks): + character_portraits_registry.update(await future) + with open(character_portraits_registry_path, 'w', encoding='utf-8') as f: + json.dump(character_portraits_registry, f, ensure_ascii=False, indent=4) + + print(f"✅ Completed character portrait generation for {len(characters)} characters.") + _emit_render_progress(progress, "character_portraits_done", "Completed character portrait generation", {"character_count": len(characters)}) + else: + print("🚀 All characters already have portraits, skipping portrait generation.") + _emit_render_progress(progress, "character_portraits_exist", "All character portraits already exist", {"character_count": len(characters)}) + return character_portraits_registry + + + async def generate_portraits_for_single_character( + self, + character: CharacterInScene, + style: str, + progress: Callable[[str, str, Dict[str, Any] | None], None] | None = None, + ): + character_dir = os.path.join(self.working_dir, "character_portraits", f"{character.idx}_{character.identifier_in_scene}") + os.makedirs(character_dir, exist_ok=True) + _emit_render_progress(progress, "character_portrait_start", f"Generating portraits for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene}) + + front_portrait_path = os.path.join(character_dir, "front.png") + if os.path.exists(front_portrait_path): + pass + else: + _emit_render_progress(progress, "character_portrait_front_start", f"Generating front portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene}) + front_portrait_output = await self.character_portraits_generator.generate_front_portrait(character, style) + front_portrait_output.save(front_portrait_path) + _emit_render_progress(progress, "character_portrait_front_done", f"Generated front portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene, "path": front_portrait_path}) + + + side_portrait_path = os.path.join(character_dir, "side.png") + if os.path.exists(side_portrait_path): + pass + else: + _emit_render_progress(progress, "character_portrait_side_start", f"Generating side portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene}) + side_portrait_output = await self.character_portraits_generator.generate_side_portrait(character, front_portrait_path) + side_portrait_output.save(side_portrait_path) + _emit_render_progress(progress, "character_portrait_side_done", f"Generated side portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene, "path": side_portrait_path}) + + back_portrait_path = os.path.join(character_dir, "back.png") + if os.path.exists(back_portrait_path): + pass + else: + _emit_render_progress(progress, "character_portrait_back_start", f"Generating back portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene}) + back_portrait_output = await self.character_portraits_generator.generate_back_portrait(character, front_portrait_path) + back_portrait_output.save(back_portrait_path) + _emit_render_progress(progress, "character_portrait_back_done", f"Generated back portrait for {character.identifier_in_scene}", {"character_idx": character.idx, "identifier": character.identifier_in_scene, "path": back_portrait_path}) + + self.character_portrait_events[character.idx].set() + + print(f"☑️ Completed character portrait generation for {character.identifier_in_scene}.") + _emit_render_progress(progress, "character_portrait_done", f"Portraits for {character.identifier_in_scene} ready", {"character_idx": character.idx, "identifier": character.identifier_in_scene}) + + return { + character.identifier_in_scene: { + "front": { + "path": front_portrait_path, + "description": f"A front view portrait of {character.identifier_in_scene}.", + }, + "side": { + "path": side_portrait_path, + "description": f"A side view portrait of {character.identifier_in_scene}.", + }, + "back": { + "path": back_portrait_path, + "description": f"A back view portrait of {character.identifier_in_scene}.", + }, + } + } + + + + async def design_storyboard( + self, + script: str, + characters: List[CharacterInScene], + user_requirement: str, + quiet: bool = False, + ): + storyboard_path = os.path.join(self.working_dir, "storyboard.json") + if os.path.exists(storyboard_path): + with open(storyboard_path, 'r', encoding='utf-8') as f: + storyboard = json.load(f) + storyboard = [ShotBriefDescription.model_validate(shot) for shot in storyboard] + _pipeline_print(quiet, f"🚀 Loaded {len(storyboard)} shot brief descriptions from existing file.") + else: + _pipeline_print(quiet, f"🔍 Designing storyboard...") + storyboard = await self.storyboard_artist.design_storyboard( + script=script, + characters=characters, + user_requirement=user_requirement, + retry_timeout=150, + ) + storyboard = _normalize_model_list(storyboard, ShotBriefDescription, "storyboard") + with open(storyboard_path, 'w', encoding='utf-8') as f: + json.dump([shot.model_dump() for shot in storyboard], f, ensure_ascii=False, indent=4) + _pipeline_print(quiet, f"✅ Designed storyboard and saved to {storyboard_path}.") + + for shot_brief_description in storyboard: + self.shot_desc_events[shot_brief_description.idx] = asyncio.Event() + + return storyboard + + + + async def decompose_visual_descriptions( + self, + shot_brief_descriptions: List[ShotBriefDescription], + characters: List[CharacterInScene], + quiet: bool = False, + ): + tasks = [ + self.decompose_visual_description_for_single_shot_brief_description(shot_brief_description, characters, quiet=quiet) + for shot_brief_description in shot_brief_descriptions + ] + + shot_descriptions = await asyncio.gather(*tasks) + return shot_descriptions + + + async def decompose_visual_description_for_single_shot_brief_description( + self, + shot_brief_description: ShotBriefDescription, + characters: List[CharacterInScene], + quiet: bool = False, + ): + shot_description_path = os.path.join(self.working_dir, "shots", f"{shot_brief_description.idx}", "shot_description.json") + os.makedirs(os.path.dirname(shot_description_path), exist_ok=True) + + if os.path.exists(shot_description_path): + with open(shot_description_path, 'r', encoding='utf-8') as f: + shot_description = ShotDescription.model_validate(json.load(f)) + _pipeline_print(quiet, f"🚀 Loaded shot {shot_brief_description.idx} description from existing file.") + else: + shot_description = await self.storyboard_artist.decompose_visual_description( + shot_brief_desc=shot_brief_description, + characters=characters, + retry_timeout=120, + ) + shot_description = _normalize_model_list([shot_description], ShotDescription, "shot_description")[0] + with open(shot_description_path, 'w', encoding='utf-8') as f: + json.dump(shot_description.model_dump(), f, ensure_ascii=False, indent=4) + _pipeline_print(quiet, f"✅ Decomposed visual description for shot {shot_brief_description.idx} and saved to {shot_description_path}.") + + self.shot_desc_events[shot_brief_description.idx].set() + + if shot_description.variation_type in ["medium", "large"]: + self.frame_events[shot_brief_description.idx] = { + "first_frame": asyncio.Event(), + "last_frame": asyncio.Event(), + } + else: + self.frame_events[shot_brief_description.idx] = { + "first_frame": asyncio.Event(), + } + + return shot_description diff --git a/prompts/agent.md b/prompts/agent.md new file mode 100644 index 0000000..3273f78 --- /dev/null +++ b/prompts/agent.md @@ -0,0 +1,5 @@ +You are the ViMax Agent, a multimodal generation agent. + +Core loop contract: +- Do not claim that planning, rendering, or file edits happened unless a tool result or `.working_dir` state proves it. +- Do not claim render has started unless `vimax_render_video` reports that it started or completed. \ No newline at end of file diff --git a/prompts/workflow.md b/prompts/workflow.md new file mode 100644 index 0000000..d7d1d1d --- /dev/null +++ b/prompts/workflow.md @@ -0,0 +1,63 @@ +ViMax supports three separate workflows: `idea2video`, `script2video`, and `novel2video`. + +Idea2Video workflow DAG: + +```text +input_idea + -> project_brief + -> characters + -> script + -> storyboard + -> shot_decomposition + -> camera_tree + -> frame_prompts + -> keyframes + -> video_clips + -> final_video +``` + +`.working_dir//` is the artifact authority. `.vimax/sessions.json` is only a session index. `.vimax/memory.md` stores user preferences only. +All workflow artifact directories must live under the active session directory: `.working_dir//idea2video/`, `.working_dir//script2video/`, and `.working_dir//novel2video/`. Never read from or write to `.working_dir/idea2video/`, `.working_dir/script2video/`, or `.working_dir/novel2video/` at the root level. + +Workflow confirmation gate: before calling any planning tool, the user must explicitly confirm which workflow to run: `idea2video`, `script2video`, or `novel2video`. Do not treat a vague idea, a request to "make a short film", or a request to "plan a script" as workflow confirmation. If the current user request does not explicitly name the workflow, do not call a planning tool; ask a concise clarification question first, for example: "Which workflow do you prefer: `idea2video`, `script2video`, or `novel2video`?" Only proceed to a planning tool after the user explicitly chooses one workflow in the current session. Source requirements still apply: `script2video` needs explicit script text for `script2video/script.txt`; `novel2video` needs explicit novel prose for `novel2video/novel/novel.txt`; vague ideas belong to `idea2video` only after the user confirms `idea2video`. + +You may help the user draft, rewrite, or discuss a script in normal assistant text before planning. Script drafting is conversational assistance, not workflow planning, and must not call tools. If you draft a script and the user wants to use it for `script2video`, ask the user to confirm that exact script before calling `vimax_narrative_planning` with the `script` argument. +Idea mode writes scene-level planning artifacts under `idea2video/scene_/`. Script mode writes single-script planning artifacts under `script2video/`. Use `vimax_narrative_planning` to create or revise structured text artifacts. Use `vimax_render_video` only when narrative planning dependencies exist. +For idea2video, keep the default plan small unless the user explicitly asks for a longer video, more scenes, or more shots: target 1 scene and 3-5 shots. Do not expand a vague idea into many scenes or many shots by default. + +Script2Video workflow DAG: + +```text +input_script + -> characters + -> storyboard + -> shot_decomposition + -> camera_tree + -> frame_prompts + -> keyframes + -> video_clips + -> final_video +``` + +Script2Video requires an explicit source script. Only use script mode when the user provides concrete script text, a screenplay, a shot list, or says to use "this script". In that case, call `vimax_narrative_planning` with the `script` argument, not `idea`. Script mode stores the exact source script at `script2video/script.txt` and writes planning artifacts under `script2video/`. Do not infer or fabricate `script2video/script.txt` from a vague idea; use idea2video for vague ideas. Do not expand a supplied script into an idea2video story first unless the user explicitly asks to rewrite or develop it as an idea. + +When the user asks to continue an existing project or fill missing text planning nodes, call `vimax_narrative_planning` for the active session. You may omit `idea` and `script`; the tool will reuse the active session source and existing cached artifacts. Do not use fake `revision_target` values such as `missing_structured_text_artifacts`; revision targets must be real relative file paths. + +After project_brief, characters, script, storyboard, shot_decomposition, and camera_tree exist, if the user did not ask for end-to-end generation or render, do not call another tool. Reply that text planning is complete and ask whether to revise or enter render. + +If the user explicitly asks for end-to-end generation, continue from planning into render tools. + + +Novel workflow DAG: + +```text +novel_text + -> compressed_novel + -> events + -> relevant_chunks + -> scenes + -> global_characters + -> scene_scripts +``` + +Novel2Video requires explicit novel prose. Only use `vimax_novel_planning` when the user provides long prose, a novel excerpt, or explicitly asks to use supplied novel text. Novel planning stores the source at `novel2video/novel/novel.txt`, then produces `novel2video/novel/novel_compressed.txt` and downstream novel artifacts. Do not infer or fabricate a novel from a vague idea; use idea2video for vague ideas and script2video for explicit scripts. `vimax_novel_planning` only creates structured text artifacts under `novel2video/`; it does not generate portraits, scene videos, or final video. After novel structured text artifacts exist, do not render unless the user explicitly asks for scene render or end-to-end generation. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9218d67 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "autolongvideogeneration" +version = "1.1.0" +description = "Add your description here" +readme = "readme.md" +requires-python = ">=3.12" +dependencies = [ + "aiohttp>=3.12.14", + "chardet>=5.2.0", + "faiss-cpu>=1.12.0", + "google-genai>=1.47.0", + "langchain>=0.3.26", + "langchain-community>=0.3.27", + "langchain-openai>=0.3.27", + "moviepy>=2.2.1", + "openai>=1.95.0", + "opencv-python", + "pillow>=11.3.0", + "pyyaml>=6.0.2", + "requests>=2.32.4", + "scenedetect[opencv]>=0.6.7.1", + "tenacity>=9.1.2", +] + +[tool.uv.sources] +torch = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +torchaudio = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + + + +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + +[dependency-groups] +dev = [ + "pytest>=8", +] diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..1cc2a13 --- /dev/null +++ b/readme.md @@ -0,0 +1,517 @@ +
+ +
+
+HKUDS%2FViMax | Trendshift +

ViMax: Agentic Video Generation

+ +
+
+ +

+ + + MIT License +

+ +

+ + +

+ +

+ + +

+ +
+
+ +

+ + +

+ + + + +
+ +--- + +### 🚨 Current Video Generation Limitations: +- ❌ **Limited to Short Clips** - Most AI tools generate only seconds of footage.
+- ❌ **Consistency Chaos** - Characters and scenes change unpredictably across frames.
+- ❌ **Visual-Only Focus** - Missing scripts, audio, narrative structure, and storytelling depth.
+ +### 💡 ViMax Solution: +🎬 **Director**, **Screenwriter**, **Producer**, and **Video Generator** **All-in-One**! We're exploring a future where AI becomes a complete creative powerhouse. 💡 Simply input your concept. ViMax autonomously handles the rest. It orchestrates scriptwriting, storyboarding, character creation, and final video generation—all end-to-end. 🚀 + +https://github.com/user-attachments/assets/5bad46b2-8276-4e1d-9480-3522640744b2 + + + + + +--- + + +### 📰 **News** + +- **2026-06-28** 🛠️ Agent Loop and TUI stability update: stronger LLM retries, persistent render status, landscape image guards, and Script2Video resume fixes. +- **2026-06-09** 📄 Technical report released. +- **2026-06-08** 🤖 Agents Loop + TUI workflow integrated for interactive planning, revision, rendering control, session reuse, and context compaction. +- **2026-06-07** 📖 Novel2Video workflow released. +- **2026-06-01** 🎬 Google Omni video generator support added. +- **2026-03-23** ⚡ MiniMax chat model provider support added. + +--- + + + +## 📑 Table of Contents + +- [💡 Key Features](#key-features) +- [🔮 Demos](#Video-Demos-Generated-from-Scratch) +- [🏗️ Architecture](#️-architecture) +- [🚀 Quick Start](#quick-start) + +--- +## 💡 Key Features + +
+ + + + + + + + +
+ +
+

🌟 Idea2Video

+
+ +
+ Algorithm Badge +
+ +
+

From Spark to Screen

+
+ +
+

Transform raw ideas into complete video stories through intelligent multi-agent workflows automating storytelling, character design, and production . +

+
+ + + +
+ +
+

🎨 Novel2Video

+
+ +
+ Frontend Badge +
+ +
+

Smart Literary Adaptation Engine

+
+ +
+

Transform complete novels into episodic video content with intelligent narrative compression, character tracking, and scene-by-scene visual adaptation

+
+ + + +
+ +
+

⚙️ Script2Video

+
+ +
+ Backend Badge +
+ +
+

Unlimited Screenplay Video Creation

+
+ +
+

Unleash your creativity by writing any screenplay from personal stories to epic adventures, giving you complete control over every aspect of your visual storytelling.

+
+ + + + + +
+ +
+

🤳 AutoCameo

+
+ +
+ Backend Badge +
+ +
+

Generate Video from Your Photo

+
+ +
+

Create your own cameo video, transforming yourself/pet into a guest star who appears across limitless creative scripts, cinematic sequences, and interactive storylines.

+
+ + + +
+ +
+ +--- + +## 🔮Video Demos Generated from Scratch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ +--- + + + +### 🎯 **End-to-End Video Creation Engine** + +**The Challenges**: + +- 🌅 **Reference Images**: Time-consuming acquisition, organization, and alignment of reference frames that accurately capture characters, objects, positions, and environments. + +- 🫠 **Consistency Check**: Sometimes, the image generator may generate unusable images even if it is given the correct characters, position, environment reference image and prompts. + +- 📄 **Scripts Generation**: Professional and high-quality videos need to have rich information density and structured design. + +- 📝 **Storyboard Design**: Converting stories into visual narratives requires expertise in cinematography, scene composition, and visual storytelling that most creators lack. + +- 🎬 **Shot Design**: Creating coherent camera sequences with proper angles, transitions, and pacing while maintaining narrative flow across complex scenes. + +- 🎨 **Development Delays**: Ensuring character appearances, environments, and artistic style remain consistent across hundreds of shots in long-form content. + +- ⏱️ **Production Efficiency**: Traditional video creation involves multiple specialists and lengthy workflows, creating barriers for independent creators and rapid prototyping. + +- 🎥 **Scaling AI Generated Video**: AI-generated videos are usually only a few seconds long, high-quality long videos at the minute or even hour level require complex cross-scene continuity and multi-storyboards design and processing capabilities. + + +**ViMAX**: eliminates these production bottlenecks by automating the entire video creation pipeline from narrative input to final video output. + +--- + + +### 🔥 **Why ViMax?** + +| 🧠 **Effortless Production** | 🚀 **Complete Creative Freedom** | 🔊 **Audio and Video Binding** | 🎨 **Professional Quality** | 🤩 **Interactive Video** +|:---:|:---:|:---:|:---:|:---:| +| One-Prompt to Finished Video | From Any Narrative to Reality | Synchronized Storytelling | Movie-Grade Output | Make Your Own Cameo Video +| Skip the technical complexity—just describe your vision and let ViMax handle script generation, storyboarding, shot design, reference management, and consistency validation | No creative limits—whether it's a trailer, short story, novel chapter, or original concept, ViMax intelligently structures narratives and designs cinematography to bring any idea to life | Seamlessly integrate character voice, and sound effects with visual content to create immersive experiences where audio and video work in perfect harmony | Automated quality control ensures character consistency, proper scene composition, and professional visual standards across every frame of your video | Interact in your own short stories by uploading your photo—ViMax intelligently integrates you as a character with consistent appearance and natural interactions throughout the entire video + +ViMax now also includes an **Agents Loop + TUI** workflow for interactive planning, revision, rendering control, session reuse, and context compaction while preserving the original direct pipeline entrypoints. + + +--- + +### ☄️ **RoadMap** + +- 🚧 🖥️ **Web frontend interface** +- 🚧 🎬 **Seedance 2.0 video generator support** +- 🚧 🖼️ **GPT-Image 2 image generator support** + +--- + + + +## 🏗️ Architecture + +### 📊 **System Overview** + +**ViMax** is a multi-agent video framework that enables automated multi-shot video generation while ensuring character and scene consistency. Our system seamlessly translates your ideas into corresponding videos, allowing you to focus on storytelling rather than technical implementation. + +🎯 **Technical Capabilities**: + +🧬 **Intelligent Long Script Generation** + +RAG-based long script design engine that intelligently analyzes lengthy, novel-like stories and automatically segments them into a multi-scene script format. The process meticulously ensures that all key plot developments and character dialogues are accurately retained within the new structure. + +🪄 **Expressive Storyboard Design** + +Shot-level storyboard design system that create expressive storyboards through cinematography language based on user requirements and target audiences, which establishs the narrative rhythm for subsequent video generation. + +🔮 **Multi-camera Filming Simulation** + +Simulates multi-camera filming to deliver an immersive viewing experience while maintaining consistent character positioning and backgrounds within the same scene. + +🧸 **Intelligent Reference Images Selection** + +Intelligently select the reference image required for the first frame of the current video, including the storyboards that occurred in the previous timeline, to ensure the accuracy of multiple characters and environmental elements as the video becomes longer. + +⚙️ **Automated Images Generation** + +Based on the selected reference image and the visual logical order on the previous timeline, the prompt of the image generator is automatically generated to reasonably arrange the spatial interaction position between the character and the environment. + +✅ **Automated Image Generation Consistency Check** + +Generate multiple images in parallel and select the best consistent image as the first frame through MLLM/VLM to imitate the workflow of human creators. + +⚡ **High-efficiency Parallel Shot Generation** + +Parallel processing for sequential shots captured from the same camera enables highly efficient video production. + + + + + +### 🤖 Multi-Agent Video Generation Pipeline + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🧠 INPUT LAYER
+ 📝 Idea & Scripts & Novels • 💭 Natural Language Prompts • 🖼️ Reference Images • 🎨 Style Directives • 🧩 Configs +
+ 🧭 CENTRAL ORCHESTRATION
+ Agent Scheduling • Stage Transitions • Resource Management • Retry/Fallback Logic +
+ 🧾 SCRIPT UNDERSTANDING
+ Character/Environment Extraction • Scene Boundaries • Style Intent +
+ 🎥 SCENE & SHOT PLANNING
+ Storyboard Steps • Shot List • Key Frames & Beats +
+ 🧪 VISUAL ASSET PLANNING
+ Reference Image Selection • Look/Style Guidance • Prompt Conditioning +
+ 🗂️ ASSET INDEXING
+ Frames/Refs Catalog • Embeddings • Retrieval for Reuse +
+ ♻️ CONSISTENCY & CONTINUITY
+ Character/Environment Tracking • Ref Matching • Temporal Coherence +
+ ✂️ VISUAL SYNTHESIS & ASSEMBLY
+ Image Generation • Best-Frame Selection • First/Last-Frame→Video • Cut & Timeline Assembly +
+ 🚀 OUTPUT LAYER
+ 🖼️ Frames • 🎞️ Clips & Final Videos • 📜 Logs • 📦 Working Directory Artifacts +
+
+ + + + + + +## 🚀Quick Start + +### 🖥️ **Environment** + +``` +OS: Linux, Windows +``` + +### 📥 **Clone and Install** +We use uv to manage the environment. For uv installation, please refer to the https://docs.astral.sh/uv/getting-started/installation/. +```bash +git clone https://github.com/HKUDS/ViMax.git +cd ViMax +uv sync +``` + + +### 🧠 **Agent TUI** +ViMax also provides a minimal TUI for interactive agent-based video creation. Configure the model and API key information in `configs/agent.local.yaml`, including the LLM, image generator, and video generator, as shown below. +```yaml +llm: + model_provider: openai + model: + base_url: + api_key: + +image: + model: + base_url: + api_key: + +video: + model: + base_url: + api_key: +``` + +Then, start the TUI from the ViMax root directory: +```bash +vimax tui +``` + +Start a new session or resume an existing one: +```bash +vimax tui new +vimax tui resume +vimax tui resume +``` + +You can also keep `configs/agent.local.yaml` empty and provide the same values through environment variables, such as `VIMAX_LLM_API_KEY`, `VIMAX_IMAGE_API_KEY`, and `VIMAX_VIDEO_API_KEY`. + +### 🎯 **Usage** +main_idea2video.py is used to convert your ideas into videos. +You need to configure the model and API key information in the configs/idea2video.yaml file, including three parts—the chat model, the image generator, and the video generator, as shown below +```yaml +chat_model: + init_args: + model: google/gemini-2.5-flash-lite-preview-09-2025 + model_provider: openai + api_key: + base_url: https://openrouter.ai/api/v1 + +image_generator: + class_path: tools.ImageGeneratorNanobananaGoogleAPI + init_args: + api_key: + +video_generator: + class_path: tools.VideoGeneratorVeoGoogleAPI + init_args: + api_key: + +working_dir: .working_dir/idea2video +``` + +Then, provide a simple yet thoughtful idea and the corresponding creative requirements in main_idea2video.py. +```bash +idea = \ +""" +If a cat and a dog are best friends, what would happen when they meet a new cat? +""" +user_requirement = \ +""" +For children, do not exceed 3 scenes. +""" +style = "Cartoon" +``` + +main_script2video.py generates a video based on a specific script. +You similarly need to set up the API configuration in configs/script2video.yaml file. Then, provide a scene script and the corresponding creative requirements in main_script2video.py, as shown below. +```python +script = \ +""" +EXT. SCHOOL GYM - DAY +A group of students are practicing basketball in the gym. The gym is large and open, with a basketball hoop at one end and a large crowd of spectators at the other end. John (18, male, tall, athletic) is the star player, and he is practicing his dribble and shot. Jane (17, female, short, athletic) is the assistant coach, and she is helping John with his practice. The other students are watching the practice and cheering for John. +John: (dribbling the ball) I'm going to score a basket! +Jane: (smiling) Good job, John! +John: (shooting the ball) Yes! +... +""" +user_requirement = \ +""" +Fast-paced with no more than 20 shots. +""" +style = "Animate Style" +``` + + +--- + +**🌟 If this project helps you, please give us a Star!** + +

+ ❤️ Thanks for visiting ✨ ViMax!

+

+ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py new file mode 100644 index 0000000..6db84b5 --- /dev/null +++ b/tests/test_agent_config.py @@ -0,0 +1,105 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +from agent_runtime.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, + load_agent_config, + reranker_api_key, + reranker_base_url, + reranker_model, + video_api_key, + video_base_url, + video_model, + video_provider, +) + + +class AgentConfigTests(unittest.TestCase): + def setUp(self): + load_agent_config.cache_clear() + + def tearDown(self): + load_agent_config.cache_clear() + + def test_reads_agent_local_config(self): + with tempfile.TemporaryDirectory() as tmp: + config_dir = Path(tmp) / "configs" + config_dir.mkdir() + (config_dir / "agent.local.yaml").write_text(yaml.safe_dump({ + "llm": {"model_provider": "openai", "model": "config-llm", "base_url": "https://config.test/v1", "api_key": "config-key"}, + "image": {"model": "config-image", "base_url": "https://image.test", "api_key": "image-key"}, + "video": {"model": "config-video", "base_url": "https://openrouter.ai/api/v1", "api_key": "video-key"}, + "embedding": {"model_provider": "openai", "model": "config-embedding", "base_url": "https://embedding.test/v1", "api_key": "embedding-key"}, + "reranker": {"model": "config-reranker", "base_url": "https://reranker.test", "api_key": "reranker-key"}, + }), encoding="utf-8") + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(llm_model(tmp), "config-llm") + self.assertEqual(llm_model_provider(tmp), "openai") + self.assertEqual(llm_base_url(tmp), "https://config.test/v1") + self.assertEqual(llm_api_key(tmp), "config-key") + self.assertEqual(image_model(tmp), "config-image") + self.assertEqual(image_base_url(tmp), "https://image.test") + self.assertEqual(image_api_key(tmp), "image-key") + self.assertEqual(video_model(tmp), "config-video") + self.assertEqual(video_provider(tmp), "openrouter") + self.assertEqual(video_base_url(tmp), "https://openrouter.ai/api/v1") + self.assertEqual(video_api_key(tmp), "video-key") + self.assertEqual(embedding_model_provider(tmp), "openai") + self.assertEqual(embedding_model(tmp), "config-embedding") + self.assertEqual(embedding_base_url(tmp), "https://embedding.test/v1") + self.assertEqual(embedding_api_key(tmp), "embedding-key") + self.assertEqual(reranker_model(tmp), "config-reranker") + self.assertEqual(reranker_base_url(tmp), "https://reranker.test") + self.assertEqual(reranker_api_key(tmp), "reranker-key") + + def test_environment_overrides_agent_local_config(self): + with tempfile.TemporaryDirectory() as tmp: + config_dir = Path(tmp) / "configs" + config_dir.mkdir() + (config_dir / "agent.local.yaml").write_text(yaml.safe_dump({"llm": {"model": "config-llm", "api_key": "config-key"}}), encoding="utf-8") + with patch.dict(os.environ, {"VIMAX_LLM_MODEL": "env-llm", "VIMAX_LLM_MODEL_PROVIDER": "openai", "VIMAX_LLM_API_KEY": "env-key", "VIMAX_VIDEO_BASE_URL": "https://openrouter.ai/api/v1", "VIMAX_EMBEDDING_MODEL": "env-embedding", "VIMAX_EMBEDDING_BASE_URL": "https://env-embedding.test/v1", "VIMAX_EMBEDDING_API_KEY": "env-embedding-key", "VIMAX_RERANKER_MODEL": "env-reranker", "VIMAX_RERANKER_BASE_URL": "https://env-reranker.test", "VIMAX_RERANKER_API_KEY": "env-reranker-key"}, clear=True): + self.assertEqual(llm_model(tmp), "env-llm") + self.assertEqual(llm_model_provider(tmp), "openai") + self.assertEqual(llm_api_key(tmp), "env-key") + self.assertEqual(video_provider(tmp), "openrouter") + self.assertEqual(video_base_url(tmp), "https://openrouter.ai/api/v1") + self.assertEqual(embedding_model(tmp), "env-embedding") + self.assertEqual(embedding_base_url(tmp), "https://env-embedding.test/v1") + self.assertEqual(embedding_api_key(tmp), "env-embedding-key") + self.assertEqual(reranker_model(tmp), "env-reranker") + self.assertEqual(reranker_base_url(tmp), "https://env-reranker.test") + self.assertEqual(reranker_api_key(tmp), "env-reranker-key") + + def test_image_and_video_keys_fall_back_to_llm_key(self): + with tempfile.TemporaryDirectory() as tmp: + config_dir = Path(tmp) / "configs" + config_dir.mkdir() + (config_dir / "agent.local.yaml").write_text(yaml.safe_dump({"llm": {"api_key": "shared-key"}}), encoding="utf-8") + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(image_api_key(tmp), "shared-key") + self.assertEqual(video_api_key(tmp), "shared-key") + + def test_video_provider_is_inferred_from_base_url(self): + self.assertEqual(api_provider_from_base_url("https://openrouter.ai/api/v1"), "openrouter") + self.assertEqual(api_provider_from_base_url("https://yunwu.ai/v1"), "yunwu") + self.assertEqual(api_provider_from_base_url("https://example.com/v1"), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_llm.py b/tests/test_agent_llm.py new file mode 100644 index 0000000..016a7b4 --- /dev/null +++ b/tests/test_agent_llm.py @@ -0,0 +1,48 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from agent_runtime.llm import OpenAICompatibleLLM + + +class AgentLLMTests(unittest.IsolatedAsyncioTestCase): + async def test_string_response_retries_before_clear_error(self): + llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key") + create = AsyncMock(side_effect=["data: [DONE]", "bad response"]) + llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + with self.assertRaisesRegex(RuntimeError, "returned a string"): + await llm.complete([], []) + self.assertEqual(create.await_count, 2) + + async def test_string_response_retry_can_recover(self): + llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key") + create = AsyncMock(side_effect=["data: [DONE]", {"choices": [{"message": {"content": "recovered", "tool_calls": []}}]}]) + llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + message = await llm.complete([], []) + self.assertEqual(message.text, "recovered") + self.assertEqual(create.await_count, 2) + + async def test_tool_request_falls_back_to_plain_chat_after_bad_tool_responses(self): + llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key") + create = AsyncMock(side_effect=[ + "data: [DONE]", + "data: [DONE]", + {"choices": [{"message": {"content": "plain fallback", "tool_calls": []}}]}, + ]) + llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + message = await llm.complete([], [{"type": "function", "function": {"name": "x", "parameters": {}}}]) + self.assertEqual(message.text, "plain fallback") + self.assertEqual(create.await_count, 3) + self.assertIsNone(create.await_args_list[-1].kwargs.get("tools")) + + async def test_dict_response_is_accepted(self): + llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key") + llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=AsyncMock(return_value={ + "choices": [{"message": {"content": "hello", "tool_calls": []}}] + })))) + message = await llm.complete([], []) + self.assertEqual(message.text, "hello") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py new file mode 100644 index 0000000..6735a6a --- /dev/null +++ b/tests/test_agent_loop.py @@ -0,0 +1,135 @@ +import asyncio +import tempfile +import unittest + +from agent_runtime.context_compactor import ContextCompactor +from agent_runtime.llm import AssistantMessage +from agent_runtime.loop import AgentLoop +from agent_runtime.models import ToolCall, ToolResult +from agent_runtime.prompts import PromptBuilder +from agent_runtime.session_index import SessionIndex +from agent_runtime.tool_executor import ToolExecutor +from agent_runtime.tools import ToolArgumentSchema, ToolRegistry, ToolSpec + + +class FakeLLM: + def __init__(self, replies): + self.replies = list(replies) + + async def complete(self, messages, tools): + return self.replies.pop(0) + + +class FailingLLM: + async def complete(self, messages, tools): + raise RuntimeError("provider returned invalid response shape") + + +class AgentLoopTests(unittest.IsolatedAsyncioTestCase): + async def test_no_tool_call_finishes(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = ToolRegistry([]) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FakeLLM([AssistantMessage(text="done")])) + events = [event async for event in loop.stream_events("hi")] + self.assertEqual(events[-2]["type"], "done") + turn_id = events[0]["turn_id"] + self.assertTrue(all(event.get("turn_id") == turn_id for event in events)) + log_text = (index.logs_dir / "loop_history.jsonl").read_text(encoding="utf-8") + self.assertIn("assistant_finished_without_tools", log_text) + + + async def test_turn_record_follows_session_created_by_tool(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + old = index.create(idea="old") + + def create_actual(args): + record = index.create(idea="actual") + return ToolResult("create_actual", True, record["session_id"]) + + registry = ToolRegistry([ToolSpec("create_actual", "Create actual session", create_actual, schema={})]) + llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="create_actual", arguments={})]), AssistantMessage(text="finished")]) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm) + events = [event async for event in loop.stream_events("start new project")] + active = index.active() + self.assertNotEqual(active["session_id"], old["session_id"]) + self.assertEqual(len(index.get(active["session_id"])["recent_turn_records"]), 1) + self.assertEqual(index.get(old["session_id"])["recent_turn_records"], []) + self.assertEqual(events[-1]["session"]["active_session_id"], active["session_id"]) + + + async def test_tool_progress_streams_before_tool_result(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + release = asyncio.Event() + + async def slow_tool(args, runtime): + runtime.emit_progress("started", stage="running") + await release.wait() + return ToolResult("slow_tool", True, "done") + + registry = ToolRegistry([ToolSpec("slow_tool", "Slow tool", slow_tool, schema={})]) + llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="slow_tool", arguments={})]), AssistantMessage(text="finished")]) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm) + agen = loop.stream_events("start") + seen = [] + while True: + event = await asyncio.wait_for(anext(agen), timeout=1) + seen.append(event["type"]) + if event["type"] == "tool_progress": + self.assertFalse(release.is_set()) + break + release.set() + async for event in agen: + seen.append(event["type"]) + self.assertLess(seen.index("tool_progress"), seen.index("tool_result")) + + + async def test_preflight_compact_summarizes_old_history(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = ToolRegistry([]) + compactor = ContextCompactor(None, token_threshold=200, buffer_tokens=0, preserve_last_n=2, summary_max_chars=2000) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FakeLLM([AssistantMessage(text="after compact")]), compactor) + loop.history = [ + {"role": "user", "content": "old request " + "x" * 1200}, + {"role": "assistant", "content": "old answer " + "y" * 1200}, + {"role": "user", "content": "recent request"}, + {"role": "assistant", "content": "recent answer"}, + ] + events = [event async for event in loop.stream_events("continue")] + self.assertIn("compact", [event.get("phase") for event in events if event["type"] == "status"]) + session = index.active() + self.assertIn("Reference Context Only", session["compacted_summary"]) + self.assertGreaterEqual(session["compacted_turns"], 1) + self.assertTrue(session["compaction_snapshots"]) + self.assertEqual(loop.history[0]["role"], "system") + self.assertIn("after compact", loop.history[-1]["content"]) + self.assertNotIn("old request", index.memory_text()) + + + async def test_llm_sampling_error_yields_error_without_crashing_loop(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = ToolRegistry([]) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FailingLLM()) + events = [event async for event in loop.stream_events("start")] + self.assertTrue(any(event["type"] == "error" and event.get("metadata", {}).get("error_type") == "llm_sampling_failed" for event in events)) + self.assertEqual(events[-2]["type"], "done") + self.assertEqual(events[-1]["type"], "session") + self.assertEqual(index.active()["recent_turn_records"][-1]["status"], "failed") + + async def test_tool_call_continues_then_finishes(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + + def hello(args): + return ToolResult("hello", True, "hello result") + + registry = ToolRegistry([ToolSpec("hello", "Say hello", hello, schema={"name": ToolArgumentSchema(str, False, "x")})]) + llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="hello", arguments={})]), AssistantMessage(text="finished")]) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm) + events = [event async for event in loop.stream_events("start")] + self.assertTrue(any(event["type"] == "tool_result" for event in events)) + self.assertEqual(events[-2]["assistant"], "finished") diff --git a/tests/test_agent_prompt_builder.py b/tests/test_agent_prompt_builder.py new file mode 100644 index 0000000..c96d5ad --- /dev/null +++ b/tests/test_agent_prompt_builder.py @@ -0,0 +1,70 @@ +import tempfile +import unittest +from pathlib import Path + +from agent_runtime.prompts import PromptBuilder +from agent_runtime.session_index import SessionIndex +from agent_runtime.tools import build_builtin_registry + + +class PromptBuilderTests(unittest.TestCase): + def test_prompt_injects_context_and_tool_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "prompts").mkdir() + (root / "prompts" / "agent.md").write_text("agent rules", encoding="utf-8") + (root / "prompts" / "workflow.md").write_text("workflow rules", encoding="utf-8") + index = SessionIndex(root) + index.create(idea="cat") + registry = build_builtin_registry(root, index) + builder = PromptBuilder(root / "prompts", index, registry) + messages = builder.build_messages("start") + self.assertIn("Available tools", messages[0]["content"]) + self.assertIn("当前 working_dir 尚未完成结构化文本文件", messages[0]["content"]) + self.assertIn("read_file", messages[0]["content"]) + trace = builder.trace(builder.build_parts("start")) + self.assertGreater(trace["total_estimated_tokens"], 0) + + + def test_prompt_injects_compacted_session_summary_as_reference(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "prompts").mkdir() + (root / "prompts" / "agent.md").write_text("agent rules", encoding="utf-8") + (root / "prompts" / "workflow.md").write_text("workflow rules", encoding="utf-8") + index = SessionIndex(root) + record = index.create(idea="cat") + index.update_compaction(record["session_id"], {"summary": "## Reference Context Only\n- user wants moon cat", "compacted_message_count": 4, "preserved_message_count": 2}) + registry = build_builtin_registry(root, index) + builder = PromptBuilder(root / "prompts", index, registry) + message = builder.build_messages("continue")[0]["content"] + self.assertIn("Session context summary", message) + self.assertIn("reference context only", message) + self.assertIn("user wants moon cat", message) + trace = builder.trace(builder.build_parts("continue")) + self.assertGreater(trace["totals"]["dynamic_tokens"], 0) + + def test_prompt_treats_novel_text_artifacts_as_text_stage_complete(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "prompts").mkdir() + (root / "prompts" / "agent.md").write_text("agent rules", encoding="utf-8") + (root / "prompts" / "workflow.md").write_text("workflow rules", encoding="utf-8") + index = SessionIndex(root) + record = index.create(idea="novel") + session_root = root / record["working_dir"] / "novel2video" + (session_root / "novel").mkdir(parents=True) + (session_root / "novel" / "novel_compressed.txt").write_text("compressed", encoding="utf-8") + (session_root / "events").mkdir() + (session_root / "events" / "event_0.json").write_text("{}", encoding="utf-8") + (session_root / "relevant_chunks" / "event_0").mkdir(parents=True) + (session_root / "relevant_chunks" / "event_0" / "chunk.txt").write_text("chunk", encoding="utf-8") + (session_root / "scenes" / "event_0").mkdir(parents=True) + (session_root / "scenes" / "event_0" / "scene_0.json").write_text("{}", encoding="utf-8") + (session_root / "global_information" / "characters" / "novel_level").mkdir(parents=True) + (session_root / "global_information" / "characters" / "novel_level" / "novel_characters_after_event_0.json").write_text("[]", encoding="utf-8") + registry = build_builtin_registry(root, index) + builder = PromptBuilder(root / "prompts", index, registry) + messages = builder.build_messages("continue") + self.assertIn("文本规划阶段已完成", messages[0]["content"]) + self.assertIn("novel2video/events/event_*.json: present", messages[0]["content"]) diff --git a/tests/test_agent_session_index.py b/tests/test_agent_session_index.py new file mode 100644 index 0000000..1c67760 --- /dev/null +++ b/tests/test_agent_session_index.py @@ -0,0 +1,69 @@ +import tempfile +import unittest +from pathlib import Path + +from agent_runtime.session_index import SessionIndex + + +class SessionIndexTests(unittest.TestCase): + def test_generated_session_id_round_trips_after_slug_truncation(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="A red ball rolls across a white table.") + self.assertIsNotNone(index.get(record["session_id"])) + self.assertEqual(index.working_dir(record["session_id"]).name, record["session_id"]) + + def test_create_session_and_checklist(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="Moon cat", user_requirement="short", style="anime") + self.assertEqual(index.active()["session_id"], record["session_id"]) + working_dir = Path(tmp) / record["working_dir"] + self.assertTrue((working_dir / "idea2video").exists()) + self.assertTrue((working_dir / "script2video").exists()) + checklist = index.artifact_checklist(record["session_id"]) + self.assertFalse(checklist["script2video/storyboard.json"]) + self.assertFalse(checklist["idea2video/scene_*/storyboard.json"]) + self.assertEqual(record["compacted_summary"], "") + self.assertEqual(record["compaction_snapshots"], []) + + + def test_session_id_is_sanitized_and_stays_under_working_dir(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(session_id="../../escaped-review") + self.assertEqual(record["session_id"], "escaped-review") + working_dir = (Path(tmp) / record["working_dir"]).resolve() + self.assertTrue(str(working_dir).startswith(str((Path(tmp) / ".working_dir").resolve()))) + self.assertFalse((Path(tmp).parent / "escaped-review").exists()) + + + def test_update_compaction_writes_session_state_not_memory(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="compact") + index.update_compaction(record["session_id"], { + "summary": "## Reference Context Only\n- old context", + "compacted_message_count": 4, + "preserved_message_count": 2, + "estimated_tokens_before": 1000, + "estimated_tokens_after": 300, + "reason": "manual", + "mode": "fallback-local", + }) + session = index.get(record["session_id"]) + self.assertIn("old context", session["compacted_summary"]) + self.assertEqual(session["compacted_turns"], 2) + self.assertEqual(session["last_compaction_reason"], "manual") + self.assertTrue(session["compaction_snapshots"]) + self.assertNotIn("old context", index.memory_text()) + + def test_memory_and_turn_record_boundaries(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create() + index.write_memory("# User Preferences\n- 16:9\n") + self.assertIn("16:9", index.memory_text()) + index.append_turn_record(record["session_id"], {"turn_id": "t1", "status": "completed", "tool_rounds": [], "final_assistant_text": "done"}) + self.assertTrue((Path(tmp) / ".vimax" / "logs" / "loop_history.jsonl").exists()) + self.assertEqual(len(index.get(record["session_id"])["recent_turn_records"]), 1) diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py new file mode 100644 index 0000000..be49947 --- /dev/null +++ b/tests/test_agent_tools.py @@ -0,0 +1,127 @@ +import asyncio +import json +import tempfile +import unittest +from pathlib import Path + +from agent_runtime.models import ToolCall, TurnControl +from agent_runtime.session_index import SessionIndex +from agent_runtime.tool_executor import ToolExecutor +from agent_runtime.tools import build_builtin_registry + + +class ToolRegistryTests(unittest.IsolatedAsyncioTestCase): + async def test_validation_default_write_json_and_logging(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + index.create() + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + record = await executor.execute(ToolCall(name="write_json", arguments={"path": "data/a.json", "data": {"x": 1}}), TurnControl()) + self.assertTrue(record.result.ok) + self.assertEqual(json.loads((Path(tmp) / "data" / "a.json").read_text())["x"], 1) + self.assertTrue((Path(tmp) / ".vimax" / "logs" / "tool_calls.jsonl").exists()) + + async def test_unknown_and_missing_argument_return_tool_errors(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + missing = await executor.execute(ToolCall(name="read_file", arguments={}), TurnControl()) + self.assertFalse(missing.result.ok) + unknown = await executor.execute(ToolCall(name="does_not_exist", arguments={}), TurnControl()) + self.assertFalse(unknown.result.ok) + + + async def test_run_shell_is_disabled_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + record = await executor.execute(ToolCall(name="run_shell", arguments={"command": "pwd"}), TurnControl()) + self.assertFalse(record.result.ok) + self.assertEqual(record.result.metadata["error_type"], "disabled") + + + async def test_todo_read_returns_empty_items_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + record = await executor.execute(ToolCall(name="todo_read", arguments={}), TurnControl()) + self.assertTrue(record.result.ok) + self.assertEqual(json.loads(record.result.content)["items"], []) + + async def test_todo_write_then_read_persists_items_and_logs(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + index.create() + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + items = [{"content": "实现 TUI", "status": "in_progress"}, {"content": "补测试"}] + written = await executor.execute(ToolCall(name="todo_write", arguments={"items": items}), TurnControl()) + self.assertTrue(written.result.ok) + todo_path = Path(tmp) / ".vimax" / "todo.json" + self.assertTrue(todo_path.exists()) + payload = json.loads(todo_path.read_text()) + self.assertEqual(payload["items"][0]["content"], "实现 TUI") + self.assertEqual(payload["items"][1]["status"], "pending") + + read = await executor.execute(ToolCall(name="todo_read", arguments={}), TurnControl()) + self.assertTrue(read.result.ok) + self.assertEqual(json.loads(read.result.content)["items"], payload["items"]) + logs = (Path(tmp) / ".vimax" / "logs" / "tool_calls.jsonl").read_text() + self.assertIn('"tool": "todo_write"', logs) + + async def test_todo_write_rejects_invalid_payload(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + not_list = await executor.execute(ToolCall(name="todo_write", arguments={"items": {"content": "x"}}), TurnControl()) + self.assertFalse(not_list.result.ok) + missing_content = await executor.execute(ToolCall(name="todo_write", arguments={"items": [{"status": "pending"}]}), TurnControl()) + self.assertFalse(missing_content.result.ok) + bad_status = await executor.execute(ToolCall(name="todo_write", arguments={"items": [{"content": "x", "status": "blocked"}]}), TurnControl()) + self.assertFalse(bad_status.result.ok) + + async def test_read_json_supports_virtual_session_json_path(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(session_id="20260630-125442-vimax", idea="surfing") + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + result = await executor.execute( + ToolCall(name="read_json", arguments={"path": f"{record['working_dir']}/session.json"}), + TurnControl(), + ) + self.assertTrue(result.result.ok) + payload = json.loads(result.result.content) + self.assertEqual(payload["session"]["session_id"], "20260630-125442-vimax") + self.assertEqual(payload["source"], ".vimax/sessions.json") + self.assertTrue(result.result.metadata["virtual_path"]) + + async def test_read_file_supports_virtual_session_log_path(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(session_id="20260630-125442-vimax", idea="surfing") + index.append_turn_record(record["session_id"], {"turn_id": "turn-1", "status": "completed", "final_assistant_text": "done"}) + registry = build_builtin_registry(tmp, index) + executor = ToolExecutor(registry, index) + result = await executor.execute( + ToolCall(name="read_file", arguments={"path": ".vimax/logs/20260630-125442-vimax.log"}), + TurnControl(), + ) + self.assertTrue(result.result.ok) + payload = json.loads(result.result.content) + self.assertEqual(payload["session_id"], "20260630-125442-vimax") + self.assertEqual(payload["source"], ".vimax/logs/*.jsonl") + self.assertEqual(payload["records"][0]["turn_id"], "turn-1") + self.assertTrue(result.result.metadata["virtual_path"]) + + def test_concurrency_partition_groups_read_tools(self): + with tempfile.TemporaryDirectory() as tmp: + registry = build_builtin_registry(tmp, SessionIndex(tmp)) + batches = registry.partition_calls([ToolCall("read_file", {"path": "x"}), ToolCall("glob_files", {"pattern": "*"}), ToolCall("write_json", {"path": "x", "data": {}})]) + self.assertEqual(len(batches), 2) + self.assertEqual(len(batches[0]), 2) diff --git a/tests/test_crash_regressions.py b/tests/test_crash_regressions.py new file mode 100644 index 0000000..ad37e66 --- /dev/null +++ b/tests/test_crash_regressions.py @@ -0,0 +1,38 @@ +"""Regression tests for small crash bugs in helper stringification paths.""" + +import unittest + +from agent_runtime.context_compactor import ContextCompactor +from interfaces.shot_description import ShotBriefDescription + + +class TestContextCompactorToolCallPreview(unittest.TestCase): + def test_fallback_summary_handles_tool_call_messages(self): + compactor = ContextCompactor(None, token_threshold=200, buffer_tokens=0, preserve_last_n=2, summary_max_chars=2000) + messages = [ + {"role": "user", "content": "list the files"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "c1", "function": {"name": "list_files", "arguments": "{}"}}]}, + ] + summary = compactor._fallback_summary(messages, [], "", "test") + self.assertIn("[tool calls]", summary) + self.assertIn("list_files", summary) + + +class TestShotBriefDescriptionStr(unittest.TestCase): + def test_str_uses_existing_fields(self): + shot = ShotBriefDescription( + idx=0, + is_last=False, + cam_idx=1, + visual_desc=" waves at the camera.", + audio_desc="[Speaker] Alice (Happy): Hello!", + ) + text = str(shot) + self.assertIn("Shot 0", text) + self.assertIn("Camera Index: 1", text) + self.assertIn(" waves at the camera.", text) + self.assertIn("[Speaker] Alice (Happy): Hello!", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generator_protocol.py b/tests/test_generator_protocol.py new file mode 100644 index 0000000..414fc3f --- /dev/null +++ b/tests/test_generator_protocol.py @@ -0,0 +1,37 @@ +"""Every video generator must satisfy the VideoGenerator protocol, which +declares **kwargs: pipelines pass progress= callbacks, and generators that +reject unknown kwargs crash mid-render (TypeError) on the transition path.""" + +import inspect +import unittest + +from tools.video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI +from tools.video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI +from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI +from tools.video_generator_veo_google_api import VideoGeneratorVeoGoogleAPI +from tools.video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI + + +class TestVideoGeneratorProtocol(unittest.TestCase): + GENERATORS = [ + VideoGeneratorDoubaoSeedanceYunwuAPI, + VideoGeneratorOmniYunwuAPI, + VideoGeneratorOpenRouterAPI, + VideoGeneratorVeoGoogleAPI, + VideoGeneratorVeoYunwuAPI, + ] + + def test_generate_single_video_accepts_arbitrary_kwargs(self): + for cls in self.GENERATORS: + with self.subTest(cls=cls.__name__): + params = inspect.signature(cls.generate_single_video).parameters + accepts_var_kwargs = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()) + self.assertTrue( + accepts_var_kwargs, + f"{cls.__name__}.generate_single_video must accept **kwargs per tools.protocols.VideoGenerator " + "(pipelines pass progress=...)", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hang_guards.py b/tests/test_hang_guards.py new file mode 100644 index 0000000..0eb53b0 --- /dev/null +++ b/tests/test_hang_guards.py @@ -0,0 +1,229 @@ +"""Regression tests for unbounded retry/polling loops and LLM-trusted graphs. + +The bugs under test previously looped forever. Each test's fakes succeed after +N calls, so the buggy code produces a fast assertion failure (extra calls or a +missing exception) rather than hanging the suite; the fixed code must give up +before the fake ever succeeds. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import requests + +from agents.camera_image_generator import _validate_camera_tree +from agents.event_extractor import EventExtractor +from interfaces.camera import Camera +from interfaces.event import Event +from pipelines.novel2movie_pipeline import _ensure_extraction_cap +from tools.video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI +from tools.video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI +from utils.image import download_image +from utils.video import download_video + + +def _no_sleep(fn): + retrying = getattr(fn, "retry", None) + if retrying is not None: + retrying.sleep = lambda seconds: None + + +class TestDownloadRetries(unittest.TestCase): + def setUp(self): + _no_sleep(download_image) + _no_sleep(download_video) + + def test_download_image_gives_up_on_persistent_network_error(self): + calls = {"n": 0} + + def flaky_get(url, **kwargs): + calls["n"] += 1 + if calls["n"] < 10: + raise requests.ConnectionError("connection refused") + return MagicMock() + + with patch("utils.image.requests.get", side_effect=flaky_get): + with self.assertRaises(requests.ConnectionError): + download_image("http://example.com/a.png", "/tmp/a.png") + self.assertLessEqual(calls["n"], 5, "retry must be bounded, not retry-until-success") + + def test_download_image_does_not_retry_client_errors(self): + calls = {"n": 0} + gone = MagicMock() + gone.raise_for_status.side_effect = requests.HTTPError( + "404", response=MagicMock(status_code=404) + ) + + def expired_then_ok(url, **kwargs): + calls["n"] += 1 + if calls["n"] < 3: + return gone + return MagicMock() + + with patch("utils.image.requests.get", side_effect=expired_then_ok): + with self.assertRaises(requests.HTTPError): + download_image("http://example.com/expired.png", "/tmp/a.png") + self.assertEqual(calls["n"], 1, "4xx responses must fail fast, not be retried") + + def test_download_image_sets_a_timeout(self): + captured = {} + + def record_get(url, **kwargs): + captured.update(kwargs) + return MagicMock() + + with patch("utils.image.requests.get", side_effect=record_get): + download_image("http://example.com/a.png", "/tmp/a.png") + self.assertIsNotNone(captured.get("timeout"), "requests.get must not wait forever") + + def test_download_video_gives_up_on_persistent_network_error(self): + calls = {"n": 0} + + def flaky_get(url, **kwargs): + calls["n"] += 1 + if calls["n"] < 10: + raise requests.ConnectionError("connection refused") + return MagicMock() + + with patch("utils.video.requests.get", side_effect=flaky_get): + with self.assertRaises(requests.ConnectionError): + download_video("http://example.com/a.mp4", "/tmp/a.mp4") + self.assertLessEqual(calls["n"], 5, "retry must be bounded, not retry-until-success") + + +class _FakeResponse: + def __init__(self, payload, status=200): + self.payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def json(self): + return self.payload + + +class _FakeSession: + """Returns each scripted (payload, status) in turn, repeating the last one.""" + + def __init__(self, scripted): + self.scripted = list(scripted) + self.calls = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def _next(self): + response = self.scripted[min(self.calls, len(self.scripted) - 1)] + self.calls += 1 + return _FakeResponse(*response) + + def post(self, url, **kwargs): + return self._next() + + def get(self, url, **kwargs): + return self._next() + + +class TestSeedanceClientBounds(unittest.IsolatedAsyncioTestCase): + async def test_create_task_fails_fast_on_auth_error(self): + session = _FakeSession([ + ({"error": "invalid api key"}, 401), + ({"error": "invalid api key"}, 401), + ({"id": "task-1"}, 200), + ]) + generator = VideoGeneratorDoubaoSeedanceYunwuAPI(api_key="bad-key") + with patch("tools.video_generator_doubao_seedance_yunwu_api.aiohttp.ClientSession", return_value=session), \ + patch("tools.video_generator_doubao_seedance_yunwu_api.asyncio.sleep", new=AsyncMock()): + with self.assertRaises(RuntimeError): + await generator.create_video_generation_task("a prompt", []) + self.assertEqual(session.calls, 1, "4xx must not be retried") + + async def test_query_task_polling_is_bounded(self): + session = _FakeSession([({"status": "queued"}, 200)]) + generator = VideoGeneratorDoubaoSeedanceYunwuAPI(api_key="key", max_poll_attempts=3) + with patch("tools.video_generator_doubao_seedance_yunwu_api.aiohttp.ClientSession", return_value=session), \ + patch("tools.video_generator_doubao_seedance_yunwu_api.asyncio.sleep", new=AsyncMock()): + with self.assertRaises(TimeoutError): + await generator.query_video_generation_task("task-1") + self.assertLessEqual(session.calls, 3) + + +class TestOmniClientBounds(unittest.IsolatedAsyncioTestCase): + def test_polling_is_bounded_by_default(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="key") + self.assertIsNotNone(generator.max_poll_attempts, "default polling must have a deadline") + + async def test_create_task_fails_fast_on_auth_error(self): + session = _FakeSession([ + ({"error": "invalid api key"}, 401), + ({"error": "invalid api key"}, 401), + ({"id": "task-1"}, 200), + ]) + generator = VideoGeneratorOmniYunwuAPI(api_key="bad-key") + with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session), \ + patch("tools.video_generator_omni_yunwu_api.asyncio.sleep", new=AsyncMock()): + with self.assertRaises(RuntimeError): + await generator.create_video_generation_task("a prompt", []) + self.assertEqual(session.calls, 1, "4xx must not be retried") + + +class TestEventExtractionCap(unittest.TestCase): + def test_extraction_aborts_when_model_never_emits_is_last(self): + extractor = object.__new__(EventExtractor) + calls = {"n": 0} + + def never_last(novel_text, extracted_events): + calls["n"] += 1 + if calls["n"] > 200: + raise AssertionError("loop was not capped") + return Event( + index=len(extracted_events), + is_last=False, + description="an event", + process_chain=["something happens"], + ) + + extractor.extract_next_event = never_last + with self.assertRaisesRegex(RuntimeError, "[Mm]ax|[Cc]ap|exceed"): + extractor("some novel text") + + def test_pipeline_extraction_cap_helper(self): + _ensure_extraction_cap(0, 50, "events") + _ensure_extraction_cap(49, 50, "events") + with self.assertRaisesRegex(RuntimeError, "events"): + _ensure_extraction_cap(50, 50, "events") + + +class TestCameraTreeValidation(unittest.TestCase): + def _camera(self, idx, parent=None): + return Camera(idx=idx, active_shot_idxs=[idx], parent_cam_idx=parent, parent_shot_idx=idx if parent is not None else None) + + def test_valid_tree_passes(self): + cameras = [self._camera(0), self._camera(1, parent=0), self._camera(2, parent=1)] + _validate_camera_tree(cameras) + + def test_cycle_is_rejected(self): + cameras = [self._camera(0, parent=1), self._camera(1, parent=0)] + with self.assertRaisesRegex(ValueError, "[Cc]ycle"): + _validate_camera_tree(cameras) + + def test_self_parent_is_rejected(self): + cameras = [self._camera(0, parent=0)] + with self.assertRaises(ValueError): + _validate_camera_tree(cameras) + + def test_unknown_parent_index_is_rejected(self): + cameras = [self._camera(0), self._camera(1, parent=7)] + with self.assertRaises(ValueError): + _validate_camera_tree(cameras) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hygiene_guards.py b/tests/test_hygiene_guards.py new file mode 100644 index 0000000..e33dd19 --- /dev/null +++ b/tests/test_hygiene_guards.py @@ -0,0 +1,117 @@ +"""Regression tests for rate-limiter lock behavior, media resource cleanup, +packaging metadata, config templates, and test-suite isolation.""" + +import asyncio +import subprocess +import sys +import tomllib +import unittest +from contextlib import suppress +from pathlib import Path +from unittest.mock import MagicMock, patch + +import yaml + +from utils.rate_limiter import RateLimiter +from utils.video import concatenate_video_files + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestRateLimiterLocking(unittest.IsolatedAsyncioTestCase): + async def test_waiting_acquirer_does_not_hold_the_lock(self): + limiter = RateLimiter(max_requests_per_minute=1) + await limiter.acquire() # consume the only slot in the window + waiter = asyncio.create_task(limiter.acquire()) + await asyncio.sleep(0.05) # waiter is now waiting for the window to free + try: + try: + await asyncio.wait_for(limiter.lock.acquire(), timeout=0.25) + limiter.lock.release() + except asyncio.TimeoutError: + self.fail("rate limiter sleeps while holding its lock, blocking every other caller") + finally: + waiter.cancel() + with suppress(asyncio.CancelledError): + await waiter + + async def test_min_delay_smoothing_still_applies(self): + limiter = RateLimiter(max_requests_per_minute=600) # min delay 0.1s + loop = asyncio.get_running_loop() + start = loop.time() + await limiter.acquire() + await limiter.acquire() + self.assertGreaterEqual(loop.time() - start, 0.08) + + +class TestVideoConcatenationCleanup(unittest.TestCase): + def test_concatenate_closes_all_clips_even_on_failure(self): + clips = [MagicMock(), MagicMock()] + final = MagicMock() + with patch("utils.video.VideoFileClip", side_effect=clips), \ + patch("utils.video.concatenate_videoclips", return_value=final): + concatenate_video_files(["a.mp4", "b.mp4"], "out.mp4") + final.write_videofile.assert_called_once() + final.close.assert_called_once() + for clip in clips: + clip.close.assert_called_once() + + # And when writing fails, the ffmpeg readers must still be released. + clips = [MagicMock(), MagicMock()] + final = MagicMock() + final.write_videofile.side_effect = OSError("disk full") + with patch("utils.video.VideoFileClip", side_effect=clips), \ + patch("utils.video.concatenate_videoclips", return_value=final): + with self.assertRaises(OSError): + concatenate_video_files(["a.mp4", "b.mp4"], "out.mp4") + final.close.assert_called_once() + for clip in clips: + clip.close.assert_called_once() + + +class TestPackagingMetadata(unittest.TestCase): + def test_pyproject_is_consistent(self): + with open(REPO_ROOT / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + readme = data["project"]["readme"] + self.assertTrue((REPO_ROOT / readme).exists(), f"readme points at missing file: {readme}") + self.assertNotIn("index", data, "top-level [[index]] is not valid pyproject TOML and is silently ignored") + dev_group = data.get("dependency-groups", {}).get("dev", []) + self.assertTrue(any("pytest" in str(item) for item in dev_group), "pytest must be a declared dev dependency so the suite runs from the venv") + + +class TestProviderConfigTemplates(unittest.TestCase): + def test_minimax_templates_do_not_ship_truthy_placeholders(self): + for name in ("idea2video_minimax.yaml", "script2video_minimax.yaml"): + with open(REPO_ROOT / "configs" / name, encoding="utf-8") as f: + config = yaml.safe_load(f) + chat_key = config["chat_model"]["init_args"].get("api_key") + self.assertFalse(chat_key, f"{name}: a truthy api_key placeholder defeats the MINIMAX_API_KEY env fallback") + for section in ("image_generator", "video_generator"): + key = config[section]["init_args"].get("api_key") + self.assertNotIn("<", str(key or ""), f"{name}: {section} ships an angle-bracket placeholder that would be sent as a bearer token") + + +class TestSuiteIsolation(unittest.TestCase): + def test_importing_minimax_tests_does_not_stub_global_modules(self): + code = ( + "import sys; import tests.test_minimax_integration; " + "mod = sys.modules.get('cv2'); " + "from unittest.mock import MagicMock; " + "exit(1 if isinstance(mod, MagicMock) else 0)" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + self.assertEqual( + result.returncode, 0, + "importing tests.test_minimax_integration replaces sys.modules entries at import time, " + "making every later-collected test module see MagicMock stubs instead of real libraries", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_orientation.py b/tests/test_image_orientation.py new file mode 100644 index 0000000..1925dd6 --- /dev/null +++ b/tests/test_image_orientation.py @@ -0,0 +1,29 @@ +import os +import unittest +from unittest.mock import patch + +from PIL import Image + +from tools.image_orientation import ensure_not_portrait, landscape_guard_requested + + +class ImageOrientationTests(unittest.TestCase): + def test_landscape_guard_requested_defaults_to_all_images(self): + self.assertTrue(landscape_guard_requested(size="1600x900")) + self.assertTrue(landscape_guard_requested(size="512x512")) + self.assertTrue(landscape_guard_requested(size=None)) + self.assertTrue(landscape_guard_requested(aspect_ratio="16:9", enforce_landscape=False)) + self.assertFalse(landscape_guard_requested(allow_portrait=True)) + + def test_portrait_detection_allows_slightly_tall_images(self): + ensure_not_portrait(Image.new("RGB", (1000, 1040))) + with self.assertRaises(ValueError): + ensure_not_portrait(Image.new("RGB", (1000, 1100))) + + def test_portrait_tolerance_env_override(self): + with patch.dict(os.environ, {"VIMAX_IMAGE_PORTRAIT_RETRY_TOLERANCE": "1.20"}): + ensure_not_portrait(Image.new("RGB", (1000, 1100))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_response.py b/tests/test_image_response.py new file mode 100644 index 0000000..91da01d --- /dev/null +++ b/tests/test_image_response.py @@ -0,0 +1,29 @@ +import base64 +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from PIL import Image + +from tools.image_response import image_from_response_part + + +class ImageResponseTests(unittest.TestCase): + def test_extracts_image_when_part_has_no_as_image_method(self): + expected = Image.new("RGB", (1, 1), (255, 0, 0)) + with patch("tools.image_response.Image.open", return_value=expected): + part = SimpleNamespace(inline_data=SimpleNamespace(data=b"fake-png")) + image = image_from_response_part(part) + self.assertEqual(image.size, (1, 1)) + + def test_extracts_base64_data_url(self): + expected = Image.new("RGB", (1, 1), (255, 0, 0)) + payload = "data:image/png;base64," + base64.b64encode(b"fake-png").decode("ascii") + with patch("tools.image_response.Image.open", return_value=expected): + part = {"inline_data": {"data": payload}} + image = image_from_response_part(part) + self.assertEqual(image.size, (1, 1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main_agent_cli.py b/tests/test_main_agent_cli.py new file mode 100644 index 0000000..5892aab --- /dev/null +++ b/tests/test_main_agent_cli.py @@ -0,0 +1,162 @@ +import contextlib +import io +import json +import sys +import unittest +from unittest.mock import patch + +import main_agent + + +class FakeSessionIndex: + def __init__(self, fail_session=False): + self.fail_session = fail_session + self.activated = "" + self.created = 0 + + def set_active(self, session_id): + if self.fail_session: + raise KeyError(session_id) + self.activated = session_id + + def create(self): + self.created += 1 + self.activated = f"new-{self.created}" + return {"session_id": self.activated} + + def snapshot(self): + return {"active_session_id": self.activated, "session": {"session_id": self.activated, "stage": "created"}} + + +class FakeRuntime: + def __init__(self, fail_session=False): + self.session_index = FakeSessionIndex(fail_session=fail_session) + self.inputs = [] + + async def compact_history(self, reason="manual"): + return "Compacted context 100 -> 50 (fallback-local)." + + async def stream_events(self, user_input): + self.inputs.append(user_input) + turn_id = "turn-test" + yield {"type": "turn", "turn_id": turn_id, "turn": {"id": turn_id}} + yield {"type": "status", "turn_id": turn_id, "phase": "sampling_assistant", "message": "Sampling assistant"} + yield {"type": "tool_progress", "turn_id": turn_id, "tool": {"name": "fake_tool"}, "progress": {"stage": "running", "message": "working"}} + yield {"type": "terminal", "turn_id": turn_id, "stream": "stdout", "line": "pipeline output"} + yield {"type": "token", "turn_id": turn_id, "delta": "done"} + yield {"type": "done", "turn_id": turn_id, "assistant": "done", "tool_results": []} + yield {"type": "session", "turn_id": turn_id, "session": {"session": {"session_id": "s1", "stage": "narrative_planned"}}} + + +class MainAgentCliTests(unittest.IsolatedAsyncioTestCase): + async def run_cli(self, argv, runtime=None, stdin_text="", session_index=None, load_runtime_side_effect=None): + runtime = runtime or FakeRuntime() + session_index = session_index or runtime.session_index + stdout = io.StringIO() + stderr = io.StringIO() + stdin = io.StringIO(stdin_text) + load_runtime_patch = patch.object(main_agent, "load_runtime", return_value=runtime) + if load_runtime_side_effect is not None: + load_runtime_patch = patch.object(main_agent, "load_runtime", side_effect=load_runtime_side_effect) + with load_runtime_patch, \ + patch.object(main_agent, "load_session_index", return_value=session_index), \ + patch.object(sys, "stdin", stdin), \ + contextlib.redirect_stdout(stdout), \ + contextlib.redirect_stderr(stderr): + code = await main_agent.amain(argv) + return code, stdout.getvalue(), stderr.getvalue(), runtime + + def test_help_parser_contains_once(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout), self.assertRaises(SystemExit) as ctx: + main_agent.parse_args(["--help"]) + self.assertEqual(ctx.exception.code, 0) + self.assertIn("--once", stdout.getvalue()) + + async def test_jsonl_once_outputs_valid_events_with_turn_id(self): + code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--once", "hello"]) + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(runtime.inputs, ["hello"]) + lines = [json.loads(line) for line in stdout.splitlines()] + self.assertTrue(lines) + self.assertEqual({event["turn_id"] for event in lines}, {"turn-test"}) + self.assertEqual(lines[0]["type"], "turn") + self.assertIn("terminal", [event["type"] for event in lines]) + self.assertNotIn("›", stdout) + + async def test_stdin_non_tty_is_single_prompt(self): + code, stdout, stderr, runtime = await self.run_cli(["--jsonl"], stdin_text="from stdin\n") + self.assertEqual(code, 0) + self.assertEqual(runtime.inputs, ["from stdin"]) + self.assertTrue(stdout.strip()) + + + async def test_stdin_repl_reads_each_line_as_a_turn(self): + code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--stdin-repl"], stdin_text="first\nsecond\n") + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(runtime.inputs, ["first", "second"]) + events = [json.loads(line) for line in stdout.splitlines()] + self.assertEqual([event["type"] for event in events if event["type"] == "done"], ["done", "done"]) + + async def test_session_error_is_clear_before_runtime_load(self): + runtime = FakeRuntime() + failing_index = FakeSessionIndex(fail_session=True) + code, stdout, stderr, runtime = await self.run_cli( + ["--session", "missing", "--once", "hello"], + runtime=runtime, + session_index=failing_index, + load_runtime_side_effect=AssertionError("runtime should not load"), + ) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertIn("unknown session id", stderr) + self.assertEqual(runtime.inputs, []) + + + async def test_new_session_is_created_before_runtime_load(self): + runtime = FakeRuntime() + session_index = FakeSessionIndex() + code, stdout, stderr, runtime = await self.run_cli( + ["--new-session", "--jsonl", "--once", "hello"], + runtime=runtime, + session_index=session_index, + ) + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(session_index.created, 1) + self.assertEqual(session_index.activated, "new-1") + self.assertEqual(runtime.inputs, ["hello"]) + + async def test_new_session_and_session_are_mutually_exclusive(self): + runtime = FakeRuntime() + code, stdout, stderr, runtime = await self.run_cli( + ["--new-session", "--session", "s1", "--once", "hello"], + runtime=runtime, + load_runtime_side_effect=AssertionError("runtime should not load"), + ) + self.assertEqual(code, 2) + self.assertEqual(stdout, "") + self.assertIn("cannot be used together", stderr) + self.assertEqual(runtime.inputs, []) + + + async def test_compact_command_outputs_jsonl_without_llm_turn(self): + code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--once", "/compact"]) + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(runtime.inputs, []) + events = [json.loads(line) for line in stdout.splitlines()] + self.assertEqual([event["type"] for event in events], ["turn", "status", "token", "done", "session"]) + turn_ids = {event["turn_id"] for event in events} + self.assertEqual(len(turn_ids), 1) + self.assertTrue(next(iter(turn_ids)).startswith("turn-")) + self.assertIn("Compacted context", events[2]["delta"]) + + async def test_plain_mode_prints_progress_terminal_and_session(self): + code, stdout, stderr, _ = await self.run_cli(["--once", "hello"]) + self.assertEqual(code, 0) + self.assertIn("tool: fake_tool running: working", stdout) + self.assertIn("terminal[stdout]: pipeline output", stdout) + self.assertIn("session: s1 narrative_planned", stdout) diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 0000000..85ce3bf --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,188 @@ +"""Integration tests for MiniMax provider support. + +These tests verify provider preset resolution and default pipeline config +loading. They mock the LangChain factory so no real API calls are made. + +Heavy multimedia dependencies (moviepy, scenedetect, cv2, google-genai, +etc.) are stubbed in setUpModule and restored in tearDownModule. Stubbing +at import time leaked the MagicMocks into sys.modules for every test module +collected after this one, making suite results import-order dependent. +""" + +import importlib +import os +import sys +import types +import unittest +from unittest.mock import patch, MagicMock + +_STUB_MODULES = [ + "moviepy", "cv2", "scenedetect", "scenedetect.detectors", + "PIL", "PIL.Image", + "faiss", + "google", "google.genai", "google.genai.types", "google.genai.errors", + "langchain_community", "langchain_community.vectorstores", + "langchain_community.vectorstores.FAISS", +] +_saved = {} +_modules_before_stubs = set() + + +def setUpModule(): + _modules_before_stubs.update(sys.modules) + for _mod in _STUB_MODULES: + _saved[_mod] = sys.modules.get(_mod) + mock = MagicMock() + # Give stub a __spec__ so importlib.util.find_spec() works + mock.__spec__ = importlib.machinery.ModuleSpec(_mod, None) + mock.__path__ = [] + sys.modules[_mod] = mock + + +def tearDownModule(): + # Drop project modules that were first imported while the stubs were + # active, so later test modules import them fresh against real libraries. + for name in list(sys.modules): + if name in _modules_before_stubs: + continue + if name.split(".")[0] in {"pipelines", "agents", "tools", "interfaces"}: + del sys.modules[name] + for _mod, original in _saved.items(): + if original is None: + sys.modules.pop(_mod, None) + else: + sys.modules[_mod] = original + _saved.clear() + _modules_before_stubs.clear() + + +from utils.provider_presets import resolve_chat_model_config + + +class TestPipelineConfigResolution(unittest.TestCase): + """Integration: config dict -> resolve -> init_chat_model kwargs.""" + + def _make_minimax_config(self, **overrides): + base = { + "model": "MiniMax-M3", + "model_provider": "minimax", + "api_key": "test-key", + } + base.update(overrides) + return base + + def test_full_minimax_config_resolution(self): + config = self._make_minimax_config() + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["model_provider"], "openai") + self.assertEqual(resolved["base_url"], "https://api.minimax.io/v1") + self.assertEqual(resolved["model"], "MiniMax-M3") + self.assertEqual(resolved["api_key"], "test-key") + + def test_minimax_highspeed_model(self): + config = self._make_minimax_config(model="MiniMax-M2.7-highspeed") + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["model"], "MiniMax-M2.7-highspeed") + self.assertEqual(resolved["model_provider"], "openai") + + def test_minimax_m27_model(self): + config = self._make_minimax_config(model="MiniMax-M2.7") + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["model"], "MiniMax-M2.7") + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "env-api-key"}) + def test_env_key_fallback_in_config(self): + config = { + "model": "MiniMax-M3", + "model_provider": "minimax", + } + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["api_key"], "env-api-key") + + def test_openrouter_config_unchanged(self): + """Existing OpenRouter configs must not be affected.""" + config = { + "model": "google/gemini-2.5-flash-lite-preview-09-2025", + "model_provider": "openai", + "api_key": "or-key", + "base_url": "https://openrouter.ai/api/v1", + } + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["model_provider"], "openai") + self.assertEqual(resolved["base_url"], "https://openrouter.ai/api/v1") + self.assertEqual(resolved["model"], "google/gemini-2.5-flash-lite-preview-09-2025") + + def test_init_chat_model_receives_openai_provider(self): + """Verify that resolved kwargs have model_provider='openai'.""" + config = self._make_minimax_config() + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["model_provider"], "openai") + self.assertEqual(resolved["base_url"], "https://api.minimax.io/v1") + self.assertEqual(resolved["model"], "MiniMax-M3") + + def test_temperature_clamping_in_pipeline_flow(self): + config = self._make_minimax_config(temperature=2.0) + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["temperature"], 1.0) + + def test_extra_kwargs_preserved(self): + config = self._make_minimax_config(max_tokens=4096, top_p=0.9) + resolved = resolve_chat_model_config(config) + self.assertEqual(resolved["max_tokens"], 4096) + self.assertEqual(resolved["top_p"], 0.9) + + +class TestPipelineInitFromConfig(unittest.TestCase): + """Integration: full pipeline init_from_config with provider configs.""" + + @patch("pipelines.idea2video_pipeline.init_chat_model") + @patch("pipelines.idea2video_pipeline.RenderBackend.from_config") + def test_idea2video_pipeline_minimax_config(self, mock_backend, mock_init): + mock_model = MagicMock() + mock_init.return_value = mock_model + mock_backend.return_value = MagicMock(image_generator=MagicMock(), video_generator=MagicMock()) + + from pipelines.idea2video_pipeline import Idea2VideoPipeline + pipeline = Idea2VideoPipeline.init_from_config("configs/idea2video_minimax.yaml") + + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + self.assertEqual(call_kwargs["model_provider"], "openai") + self.assertEqual(call_kwargs["base_url"], "https://api.minimax.io/v1") + self.assertEqual(call_kwargs["model"], "MiniMax-M3") + + @patch("pipelines.script2video_pipeline.init_chat_model") + @patch("pipelines.script2video_pipeline.RenderBackend.from_config") + def test_script2video_pipeline_minimax_config(self, mock_backend, mock_init): + mock_model = MagicMock() + mock_init.return_value = mock_model + mock_backend.return_value = MagicMock(image_generator=MagicMock(), video_generator=MagicMock()) + + from pipelines.script2video_pipeline import Script2VideoPipeline + pipeline = Script2VideoPipeline.init_from_config("configs/script2video_minimax.yaml") + + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + self.assertEqual(call_kwargs["model_provider"], "openai") + self.assertEqual(call_kwargs["base_url"], "https://api.minimax.io/v1") + self.assertEqual(call_kwargs["model"], "MiniMax-M3") + """Integration: full pipeline init_from_config with default configs.""" + + @patch("pipelines.idea2video_pipeline.init_chat_model") + @patch("pipelines.idea2video_pipeline.RenderBackend.from_config") + def test_existing_openrouter_config_still_works(self, mock_backend, mock_init): + mock_model = MagicMock() + mock_init.return_value = mock_model + mock_backend.return_value = MagicMock(image_generator=MagicMock(), video_generator=MagicMock()) + + from pipelines.idea2video_pipeline import Idea2VideoPipeline + pipeline = Idea2VideoPipeline.init_from_config("configs/idea2video.yaml") + + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + self.assertEqual(call_kwargs["model_provider"], "openai") + self.assertEqual(call_kwargs["base_url"], "https://openrouter.ai/api/v1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_novel2movie_pipeline_init.py b/tests/test_novel2movie_pipeline_init.py new file mode 100644 index 0000000..fff795d --- /dev/null +++ b/tests/test_novel2movie_pipeline_init.py @@ -0,0 +1,52 @@ +"""Tests for Novel2MoviePipeline initialization.""" + +import ast +from pathlib import Path +import unittest + + +class TestNovel2MoviePipelineInit(unittest.TestCase): + def _class_node(self): + source = Path("pipelines/novel2movie_pipeline.py").read_text(encoding="utf-8") + tree = ast.parse(source) + return next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "Novel2MoviePipeline" + ) + + def test_initializes_runtime_dependencies(self): + class_node = self._class_node() + init_node = next( + node + for node in class_node.body + if isinstance(node, ast.FunctionDef) and node.name == "__init__" + ) + assigned = { + target.attr + for node in ast.walk(init_node) + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + } + + self.assertTrue( + { + "working_dir", + "novel_compressor", + "event_extractor", + "embeddings", + "rerank_model", + "scene_extractor", + "global_information_planner", + "image_generator", + "rewriter", + "script2video_pipeline", + }.issubset(assigned) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_novel2video_adapter.py b/tests/test_novel2video_adapter.py new file mode 100644 index 0000000..9cedee4 --- /dev/null +++ b/tests/test_novel2video_adapter.py @@ -0,0 +1,277 @@ +import contextlib +import io +import json +import logging +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from interfaces import CharacterInEvent, CharacterInNovel, CharacterInScene, Event, Scene +from interfaces.environment import EnvironmentInScene +from agent_runtime.session_index import SessionIndex +from agent_runtime.tools import ToolRuntimeContext +from agent_runtime.vimax_adapters import ViMaxAdapters, _run_planning_step +from agents.global_information_planner import GlobalInformationPlanner, MergeCharactersAcrossScenesInEventResponse +from pipelines.novel2movie_pipeline import Novel2MoviePipeline + + +class FakeCompressor: + def split(self, novel_text): + return [novel_text] + + async def compress_single_novel_chunk(self, semaphore, index, novel_chunk): + return index, f"compressed {novel_chunk}" + + def aggregate(self, chunks): + return "\n".join(chunks) + + +class FakeEventExtractor: + def extract_next_event(self, novel_text, extracted_events): + return Event(index=len(extracted_events), is_last=True, description="Hero leaves home", process_chain=["Hero opens the door"]) + + +class FakeKnowledgeBase: + def similarity_search(self, process, k=10): + return [SimpleNamespace(page_content="Hero opens the old wooden door.")] + + +class FakeReranker: + async def __call__(self, documents, query, top_n): + return [(documents[0], 0.95)] + + +class FakeSceneExtractor: + async def get_next_scene(self, relevant_chunks, event, previous_scenes): + return Scene( + idx=len(previous_scenes), + is_last=True, + environment=EnvironmentInScene(slugline="INT. HOUSE - DAY", description="A quiet room."), + characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")], + script=" opens the door.", + ) + + +class FakeGlobalPlanner: + async def merge_characters_across_scenes_in_event(self, event_idx, scenes): + return [CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult")] + + def merge_characters_to_existing_characters_in_novel(self, event_idx, existing_characters_in_novel, characters_in_event): + return [CharacterInNovel(index=0, identifier_in_novel="Hero", active_events={event_idx: "Hero"}, static_features="adult")] + + +class FakeNovelPipeline: + def __init__(self, working_dir: Path): + self.working_dir = working_dir + + async def plan_text_artifacts(self, novel_text, user_requirement="", style="", progress=None, quiet=False): + if progress: + for stage in ["save_novel", "compress_novel", "extract_events", "retrieve_chunks", "extract_scenes", "merge_characters", "completed"]: + progress(stage, stage, {}) + novel = self.working_dir / "novel" + novel.mkdir(parents=True, exist_ok=True) + (novel / "novel.txt").write_text(novel_text, encoding="utf-8") + (novel / "novel_compressed.txt").write_text("compressed", encoding="utf-8") + events = self.working_dir / "events" + events.mkdir(parents=True, exist_ok=True) + (events / "event_0.json").write_text(json.dumps(Event(index=0, is_last=True, description="d", process_chain=["p"]).model_dump()), encoding="utf-8") + chunks = self.working_dir / "relevant_chunks" / "event_0" + chunks.mkdir(parents=True, exist_ok=True) + (chunks / "chunk_0-score_0.95.txt").write_text("chunk", encoding="utf-8") + scenes = self.working_dir / "scenes" / "event_0" + scenes.mkdir(parents=True, exist_ok=True) + scene = Scene(idx=0, is_last=True, environment=EnvironmentInScene(slugline="INT. ROOM - DAY", description="room"), characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")], script=" walks.") + (scenes / "scene_0.json").write_text(json.dumps(scene.model_dump()), encoding="utf-8") + event_level = self.working_dir / "global_information" / "characters" / "event_level" + event_level.mkdir(parents=True, exist_ok=True) + event_char = CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult") + (event_level / "event_0_characters.json").write_text(json.dumps([event_char.model_dump()]), encoding="utf-8") + novel_level = self.working_dir / "global_information" / "characters" / "novel_level" + novel_level.mkdir(parents=True, exist_ok=True) + novel_char = CharacterInNovel(index=0, identifier_in_novel="Hero", active_events={0: "Hero"}, static_features="adult") + (novel_level / "novel_characters_after_event_0.json").write_text(json.dumps([novel_char.model_dump()]), encoding="utf-8") + return {} + + +class FakeMergeChain: + async def ainvoke(self, messages): + return MergeCharactersAcrossScenesInEventResponse( + characters=[CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult")] + ) + + +class FakeMergeChatModel: + def __or__(self, parser): + return FakeMergeChain() + + +class GlobalInformationPlannerCompatibilityTests(unittest.IsolatedAsyncioTestCase): + async def test_merge_event_characters_uses_scene_character_idx(self): + planner = GlobalInformationPlanner.__new__(GlobalInformationPlanner) + planner.chat_model = FakeMergeChatModel() + scene = Scene( + idx=0, + is_last=True, + environment=EnvironmentInScene(slugline="INT. ROOM - DAY", description="room"), + characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")], + script=" walks.", + ) + characters = await planner.merge_characters_across_scenes_in_event(event_idx=0, scenes=[scene]) + self.assertEqual(characters[0].identifier_in_event, "Hero") + + +class PlanningStepOutputSuppressionTests(unittest.IsolatedAsyncioTestCase): + async def test_run_planning_step_suppresses_stdout_stderr_and_warnings(self): + async def noisy_step(): + print("NOISE_STDOUT") + logging.warning("NOISE_WARNING") + return "ok" + + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + result = await _run_planning_step("message", "stage", noisy_step(), runtime=None) + self.assertEqual(result, "ok") + self.assertNotIn("NOISE_STDOUT", stdout.getvalue()) + self.assertNotIn("NOISE_WARNING", stderr.getvalue()) + + +class Novel2MoviePlanningTests(unittest.IsolatedAsyncioTestCase): + async def test_plan_text_artifacts_writes_structured_text_and_progress(self): + with tempfile.TemporaryDirectory() as tmp: + pipeline = Novel2MoviePipeline( + novel_compressor=FakeCompressor(), + event_extractor=FakeEventExtractor(), + embeddings=SimpleNamespace(model="fake-embedding"), + rerank_model=FakeReranker(), + scene_extractor=FakeSceneExtractor(), + global_information_planner=FakeGlobalPlanner(), + image_generator=object(), + rewriter=object(), + script2video_pipeline=object(), + working_dir=tmp, + ) + events = [] + with patch("pipelines.novel2movie_pipeline.CacheBackedEmbeddings.from_bytes_store", return_value=object()), \ + patch("pipelines.novel2movie_pipeline.FAISS.from_texts", return_value=FakeKnowledgeBase()): + result = await pipeline.plan_text_artifacts("Hero opens a door.", progress=lambda stage, message, metadata=None: events.append(stage), quiet=True) + self.assertEqual(events, ["save_novel", "compress_novel", "extract_events", "retrieve_chunks", "extract_scenes", "merge_characters", "completed"]) + root = Path(tmp) + self.assertTrue((root / "novel" / "novel_compressed.txt").exists()) + self.assertTrue((root / "events" / "event_0.json").exists()) + self.assertTrue((root / "relevant_chunks" / "event_0" / "chunk_0-score_0.95.txt").exists()) + self.assertTrue((root / "scenes" / "event_0" / "scene_0.json").exists()) + self.assertTrue((root / "global_information" / "characters" / "novel_level" / "novel_characters_after_event_0.json").exists()) + self.assertFalse((root / "character_portraits").exists()) + self.assertFalse((root / "videos").exists()) + self.assertEqual(len(result["events"]), 1) + + +class FakeNovelRenderPipeline: + def __init__(self, working_dir: Path): + self.working_dir = Path(working_dir) + + async def render_video_artifacts(self, style, user_requirement="", progress=None, quiet=False): + if progress: + progress("novel_portraits_start", "portraits", {}) + progress("novel_scene_render_start", "scene", {"event_idx": 0, "scene_idx": 0}) + progress("novel_render_completed", "done", {"scene_count": 1}) + scene_dir = self.working_dir / "videos" / "event_0" / "scene_0" + scene_dir.mkdir(parents=True, exist_ok=True) + (scene_dir / "final_video.mp4").write_text("video", encoding="utf-8") + return { + "character_portraits_dir": str(self.working_dir / "character_portraits"), + "scene_videos_dir": str(self.working_dir / "videos"), + "scene_video_dirs": [str(scene_dir)], + "scene_count": 1, + } + + +def write_minimal_novel_artifacts(root: Path): + novel = root / "novel2video" + (novel / "novel").mkdir(parents=True, exist_ok=True) + (novel / "novel" / "novel.txt").write_text("novel", encoding="utf-8") + (novel / "novel" / "novel_compressed.txt").write_text("compressed", encoding="utf-8") + events = novel / "events" + events.mkdir(parents=True, exist_ok=True) + (events / "event_0.json").write_text(json.dumps(Event(index=0, is_last=True, description="d", process_chain=["p"]).model_dump()), encoding="utf-8") + chunks = novel / "relevant_chunks" / "event_0" + chunks.mkdir(parents=True, exist_ok=True) + (chunks / "chunk_0-score_0.95.txt").write_text("chunk", encoding="utf-8") + scenes = novel / "scenes" / "event_0" + scenes.mkdir(parents=True, exist_ok=True) + scene = Scene(idx=0, is_last=True, environment=EnvironmentInScene(slugline="INT. ROOM - DAY", description="room"), characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")], script=" walks.") + (scenes / "scene_0.json").write_text(json.dumps(scene.model_dump()), encoding="utf-8") + event_level = novel / "global_information" / "characters" / "event_level" + event_level.mkdir(parents=True, exist_ok=True) + event_char = CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult") + (event_level / "event_0_characters.json").write_text(json.dumps([event_char.model_dump()]), encoding="utf-8") + novel_level = novel / "global_information" / "characters" / "novel_level" + novel_level.mkdir(parents=True, exist_ok=True) + novel_char = CharacterInNovel(index=0, identifier_in_novel="Hero", active_events={0: "Hero"}, static_features="adult") + (novel_level / "novel_characters_after_event_0.json").write_text(json.dumps([novel_char.model_dump()]), encoding="utf-8") + + +class NovelAdapterTests(unittest.IsolatedAsyncioTestCase): + async def test_missing_rag_config_returns_tool_error_and_marks_session_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + with patch.dict("os.environ", {"VIMAX_LLM_API_KEY": "llm-key", "VIMAX_LLM_BASE_URL": "https://llm.test/v1"}, clear=True), \ + patch("agent_runtime.vimax_adapters._build_embedding_model", side_effect=RuntimeError("embedding config missing")): + result = await adapter.vimax_novel_planning({"novel_text": "Hero opens a door."}) + self.assertFalse(result.ok) + self.assertIn("embedding", result.content.lower()) + self.assertEqual(index.active()["stage"], "error") + + async def test_success_writes_novel2video_artifacts_and_marks_novel_planned(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + progress_events = [] + runtime = ToolRuntimeContext("vimax_novel_planning", "vimax_novel_planning", turn_id="turn-test", progress_callback=progress_events.append) + with patch("agent_runtime.vimax_adapters._build_novel_pipeline", side_effect=lambda working_dir: FakeNovelPipeline(Path(working_dir))): + result = await adapter.vimax_novel_planning({"novel_text": "Hero opens a door.", "style": "noir"}, runtime) + self.assertTrue(result.ok) + payload = json.loads(result.content) + root = Path(tmp) / payload["working_dir"] + self.assertTrue((root / "novel2video" / "novel" / "novel_compressed.txt").exists()) + self.assertTrue((root / "novel2video" / "events" / "event_0.json").exists()) + self.assertIn("novel2video/scenes/event_*/scene_*.json", payload["generated"]) + self.assertFalse(payload["ready_for_scene_render"]) + self.assertEqual(index.active()["stage"], "novel_planned") + stages = [event["progress"]["stage"] for event in progress_events if event.get("type") == "tool_progress"] + self.assertIn("novel_plan_text_artifacts", stages) + self.assertIn("merge_characters", stages) + + async def test_render_video_routes_novel2video_with_mock_pipeline(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="novel", user_requirement="scene render", style="noir") + root = Path(tmp) / record["working_dir"] + write_minimal_novel_artifacts(root) + adapter = ViMaxAdapters(Path(tmp), index) + progress_events = [] + runtime = ToolRuntimeContext("vimax_render_video", "vimax_render_video", turn_id="turn-test", progress_callback=progress_events.append) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_novel_render_pipeline", side_effect=lambda working_dir, chat_model, image_generator, video_generator: FakeNovelRenderPipeline(Path(working_dir))): + result = await adapter.vimax_render_video({}, runtime) + self.assertTrue(result.ok) + payload = json.loads(result.content) + self.assertEqual(payload["render_mode"], "novel2video") + self.assertTrue(payload["scene_render_completed"]) + self.assertIsNone(payload["final_video_path"]) + self.assertEqual(payload["scene_count"], 1) + self.assertEqual(index.get(record["session_id"])["stage"], "novel_scene_rendered") + self.assertTrue((root / "novel2video" / "videos" / "event_0" / "scene_0" / "final_video.mp4").exists()) + stages = [event["progress"]["stage"] for event in progress_events if event.get("type") == "tool_progress"] + self.assertIn("novel_scene_render_start", stages) + self.assertIn("novel_render_completed", stages) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_omni_yunwu_video_generator.py b/tests/test_omni_yunwu_video_generator.py new file mode 100644 index 0000000..bd79ac6 --- /dev/null +++ b/tests/test_omni_yunwu_video_generator.py @@ -0,0 +1,136 @@ +import unittest +from unittest.mock import patch + +from tools.video_generator_omni_yunwu_api import ( + VideoGeneratorOmniYunwuAPI, + VideoGeneratorOminiYunwuAPI, +) + + +class _FakeResponse: + def __init__(self, payload): + self.payload = payload + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def json(self): + return self.payload + + +class _FakeSession: + def __init__(self, payload): + self.payload = payload + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def get(self, url, **kwargs): + self.calls.append(("get", url, kwargs)) + return _FakeResponse(self.payload) + + +class TestVideoGeneratorOmniYunwuAPI(unittest.IsolatedAsyncioTestCase): + def test_text_to_video_payload(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="test-key") + + payload = generator._build_payload( + prompt="hello world", + reference_image_paths=[], + aspect_ratio="16:9", + seconds=8, + size=None, + enable_upsample=False, + enable_sample=None, + ) + + self.assertEqual(payload["model"], "omni-flash") + self.assertEqual(payload["type"], 1) + self.assertEqual(payload["seconds"], "8") + self.assertEqual(payload["aspect_ratio"], "16:9") + self.assertFalse(payload["enable_upsample"]) + self.assertNotIn("images", payload) + + def test_first_last_frame_payload(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="test-key") + + payload = generator._build_payload( + prompt="transition", + reference_image_paths=["https://example.com/first.png", "https://example.com/last.png"], + aspect_ratio="9:16", + seconds=6, + size=None, + enable_upsample=None, + enable_sample=True, + ) + + self.assertEqual(payload["type"], 2) + self.assertEqual(payload["images"], ["https://example.com/first.png", "https://example.com/last.png"]) + self.assertEqual(payload["seconds"], "6") + self.assertTrue(payload["enable_sample"]) + + def test_three_reference_images_use_reference_mode(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="test-key") + + payload = generator._build_payload( + prompt="blend references", + reference_image_paths=[ + "https://example.com/1.png", + "https://example.com/2.png", + "https://example.com/3.png", + ], + aspect_ratio="16:9", + seconds=None, + size=None, + enable_upsample=None, + enable_sample=None, + ) + + self.assertEqual(payload["type"], 3) + self.assertEqual(len(payload["images"]), 3) + + def test_too_many_reference_images_raise(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="test-key") + + with self.assertRaises(ValueError): + generator._build_payload( + prompt="too many", + reference_image_paths=["1", "2", "3", "4"], + aspect_ratio="16:9", + seconds=None, + size=None, + enable_upsample=None, + enable_sample=None, + ) + + async def test_query_completed_uses_top_level_video_url(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="test-key", poll_interval=0, max_poll_attempts=1) + session = _FakeSession({"status": "completed", "video_url": "https://example.com/out.mp4"}) + + with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session): + video_url = await generator.query_video_generation_task("task-1", "omni-flash") + + self.assertEqual(video_url, "https://example.com/out.mp4") + self.assertEqual(session.calls[0][2]["params"], {"id": "task-1", "model": "omni-flash"}) + + async def test_query_failed_raises(self): + generator = VideoGeneratorOmniYunwuAPI(api_key="test-key", poll_interval=0, max_poll_attempts=1) + session = _FakeSession({"status": "failed", "error": "视频生成失败"}) + + with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session): + with self.assertRaises(RuntimeError): + await generator.query_video_generation_task("task-1", "omni-flash") + + def test_omini_alias(self): + self.assertTrue(issubclass(VideoGeneratorOminiYunwuAPI, VideoGeneratorOmniYunwuAPI)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openrouter_video_generator.py b/tests/test_openrouter_video_generator.py new file mode 100644 index 0000000..54820fc --- /dev/null +++ b/tests/test_openrouter_video_generator.py @@ -0,0 +1,40 @@ +import os +import unittest +from unittest.mock import patch + +from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI +from interfaces.video_output import VideoOutput + + +class OpenRouterVideoGeneratorTests(unittest.IsolatedAsyncioTestCase): + async def test_default_duration_is_eight_seconds(self): + captured = {} + + async def fake_post_json(url, *, headers, payload, timeout, hard_timeout_seconds): + captured["payload"] = payload + return 200, {"id": "job-1", "polling_url": "/videos/job-1", "status": "queued"} + + async def fake_get_json(url, *, headers, timeout, hard_timeout_seconds): + return 200, {"status": "completed", "unsigned_urls": ["https://cdn.example/out.mp4"]} + + async def fake_get_bytes(url, *, headers, timeout, hard_timeout_seconds): + return 200, b"video" + + async def fake_sleep(seconds): + return None + + generator = VideoGeneratorOpenRouterAPI(api_key="test-key", model="google/veo-3.1-lite") + with patch.dict(os.environ, {}, clear=True), \ + patch("tools.video_generator_openrouter_api._post_json", fake_post_json), \ + patch("tools.video_generator_openrouter_api._get_json", fake_get_json), \ + patch("tools.video_generator_openrouter_api._get_bytes", fake_get_bytes), \ + patch("tools.video_generator_openrouter_api.asyncio.sleep", fake_sleep): + output = await generator.generate_single_video(prompt="hello") + + self.assertIsInstance(output, VideoOutput) + self.assertEqual(captured["payload"]["duration"], 8) + self.assertEqual(captured["payload"]["model"], "google/veo-3.1-lite") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_presets.py b/tests/test_provider_presets.py new file mode 100644 index 0000000..525c89e --- /dev/null +++ b/tests/test_provider_presets.py @@ -0,0 +1,175 @@ +"""Unit tests for utils.provider_presets.""" + +import os +import unittest +from unittest.mock import patch + +from utils.provider_presets import ( + PROVIDER_PRESETS, + resolve_chat_model_config, + detect_provider_from_env, +) + + +class TestProviderPresets(unittest.TestCase): + """Tests for the PROVIDER_PRESETS registry.""" + + def test_minimax_preset_exists(self): + self.assertIn("minimax", PROVIDER_PRESETS) + + def test_minimax_preset_base_url(self): + self.assertEqual( + PROVIDER_PRESETS["minimax"]["base_url"], + "https://api.minimax.io/v1", + ) + + def test_minimax_preset_env_key(self): + self.assertEqual(PROVIDER_PRESETS["minimax"]["env_key"], "MINIMAX_API_KEY") + + def test_minimax_preset_default_model(self): + self.assertEqual(PROVIDER_PRESETS["minimax"]["default_model"], "MiniMax-M3") + + def test_minimax_preset_has_models_list(self): + models = PROVIDER_PRESETS["minimax"]["models"] + self.assertIn("MiniMax-M3", models) + self.assertIn("MiniMax-M2.7", models) + self.assertIn("MiniMax-M2.7-highspeed", models) + + def test_minimax_preset_temperature_range(self): + lo, hi = PROVIDER_PRESETS["minimax"]["temperature_range"] + self.assertEqual(lo, 0.0) + self.assertEqual(hi, 1.0) + + +class TestResolveChatModelConfig(unittest.TestCase): + """Tests for resolve_chat_model_config().""" + + def test_unknown_provider_passes_through(self): + args = {"model_provider": "openai", "model": "gpt-4", "base_url": "https://example.com"} + result = resolve_chat_model_config(args) + self.assertEqual(result["model_provider"], "openai") + self.assertEqual(result["model"], "gpt-4") + self.assertEqual(result["base_url"], "https://example.com") + + def test_no_model_provider_passes_through(self): + args = {"model": "gpt-4"} + result = resolve_chat_model_config(args) + self.assertEqual(result["model"], "gpt-4") + + def test_minimax_rewrites_provider_to_openai(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk-test"} + result = resolve_chat_model_config(args) + self.assertEqual(result["model_provider"], "openai") + + def test_minimax_sets_base_url(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk-test"} + result = resolve_chat_model_config(args) + self.assertEqual(result["base_url"], "https://api.minimax.io/v1") + + def test_minimax_preserves_custom_base_url(self): + args = { + "model_provider": "minimax", + "model": "MiniMax-M3", + "api_key": "sk-test", + "base_url": "https://custom-proxy.example.com/v1", + } + result = resolve_chat_model_config(args) + self.assertEqual(result["base_url"], "https://custom-proxy.example.com/v1") + + def test_minimax_defaults_model(self): + args = {"model_provider": "minimax", "api_key": "sk-test"} + result = resolve_chat_model_config(args) + self.assertEqual(result["model"], "MiniMax-M3") + + def test_minimax_preserves_explicit_model(self): + args = {"model_provider": "minimax", "model": "MiniMax-M2.7-highspeed", "api_key": "sk-test"} + result = resolve_chat_model_config(args) + self.assertEqual(result["model"], "MiniMax-M2.7-highspeed") + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key-123"}) + def test_minimax_reads_api_key_from_env(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3"} + result = resolve_chat_model_config(args) + self.assertEqual(result["api_key"], "env-key-123") + + def test_minimax_prefers_explicit_api_key_over_env(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "explicit-key"} + with patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key"}): + result = resolve_chat_model_config(args) + self.assertEqual(result["api_key"], "explicit-key") + + def test_minimax_clamps_temperature_above_max(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": 1.5} + result = resolve_chat_model_config(args) + self.assertEqual(result["temperature"], 1.0) + + def test_minimax_clamps_temperature_below_min(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": -0.5} + result = resolve_chat_model_config(args) + self.assertEqual(result["temperature"], 0.0) + + def test_minimax_passes_valid_temperature(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": 0.7} + result = resolve_chat_model_config(args) + self.assertEqual(result["temperature"], 0.7) + + def test_minimax_temperature_zero_allowed(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": 0.0} + result = resolve_chat_model_config(args) + self.assertEqual(result["temperature"], 0.0) + + def test_minimax_no_temperature_key(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk"} + result = resolve_chat_model_config(args) + self.assertNotIn("temperature", result) + + def test_minimax_temperature_none_ignored(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": None} + result = resolve_chat_model_config(args) + self.assertIsNone(result["temperature"]) + + def test_original_dict_not_mutated(self): + args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk"} + resolve_chat_model_config(args) + self.assertEqual(args["model_provider"], "minimax") + + def test_empty_model_string_gets_default(self): + args = {"model_provider": "minimax", "model": "", "api_key": "sk"} + result = resolve_chat_model_config(args) + self.assertEqual(result["model"], "MiniMax-M3") + + +class TestDetectProviderFromEnv(unittest.TestCase): + """Tests for detect_provider_from_env().""" + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"}, clear=False) + def test_detects_minimax(self): + self.assertEqual(detect_provider_from_env(), "minimax") + + @patch.dict(os.environ, {}, clear=True) + def test_returns_none_when_no_keys(self): + self.assertIsNone(detect_provider_from_env()) + + +class TestConfigYAMLLoading(unittest.TestCase): + """Test that MiniMax example config files are valid YAML.""" + + def test_idea2video_minimax_yaml(self): + import yaml + path = os.path.join(os.path.dirname(__file__), "..", "configs", "idea2video_minimax.yaml") + with open(path) as f: + config = yaml.safe_load(f) + self.assertEqual(config["chat_model"]["init_args"]["model_provider"], "minimax") + self.assertEqual(config["chat_model"]["init_args"]["model"], "MiniMax-M3") + + def test_script2video_minimax_yaml(self): + import yaml + path = os.path.join(os.path.dirname(__file__), "..", "configs", "script2video_minimax.yaml") + with open(path) as f: + config = yaml.safe_load(f) + self.assertEqual(config["chat_model"]["init_args"]["model_provider"], "minimax") + self.assertEqual(config["chat_model"]["init_args"]["model"], "MiniMax-M3") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_robustness.py b/tests/test_robustness.py new file mode 100644 index 0000000..a0582ff --- /dev/null +++ b/tests/test_robustness.py @@ -0,0 +1,225 @@ +"""Regression tests for error-boundary and durability fixes. + +Covers: LLM client retry/empty-choices handling, agent-loop turn error +boundary, session index corruption/atomicity/concurrency, and bounded +retry policies with backoff across agents and API clients. +""" + +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from tenacity.stop import stop_never +from tenacity.wait import wait_none + +from agent_runtime.llm import OpenAICompatibleLLM +from agent_runtime.loop import AgentLoop +from agent_runtime.prompts import PromptBuilder +from agent_runtime.session_index import SessionIndex +from agent_runtime.tool_executor import ToolExecutor +from agent_runtime.tools import ToolRegistry +from agents.screenwriter import Screenwriter +from agents.script_planner import ScriptPlanner +from tools.image_generator_doubao_seedream_yunwu_api import ImageGeneratorDoubaoSeedreamYunwuAPI +from tools.image_generator_nanobanana_google_api import ImageGeneratorNanobananaGoogleAPI +from tools.image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI +from tools.reranker_bge_silicon_api import RerankerBgeSiliconapi + + +class FakeStatusError(Exception): + def __init__(self, status_code): + self.status_code = status_code + super().__init__(f"http status {status_code}") + + +def _fake_completion(text="ok"): + message = MagicMock() + message.content = text + message.tool_calls = None + message.model_dump.return_value = {} + return MagicMock(choices=[MagicMock(message=message)]) + + +class TestLLMClient(unittest.IsolatedAsyncioTestCase): + def _llm(self, create): + llm = OpenAICompatibleLLM(model="m", base_url="http://localhost:1", api_key="k") + llm.client = MagicMock(chat=MagicMock(completions=MagicMock(create=create))) + return llm + + async def test_retries_rate_limit_then_succeeds(self): + create = AsyncMock(side_effect=[FakeStatusError(429), _fake_completion("recovered")]) + llm = self._llm(create) + result = await llm.complete([{"role": "user", "content": "x"}], tools=[]) + self.assertEqual(result.text, "recovered") + self.assertEqual(create.await_count, 2) + + async def test_does_not_retry_auth_errors(self): + create = AsyncMock(side_effect=FakeStatusError(401)) + llm = self._llm(create) + with self.assertRaises(FakeStatusError): + await llm.complete([{"role": "user", "content": "x"}], tools=[]) + self.assertEqual(create.await_count, 1) + + async def test_gives_up_after_bounded_attempts(self): + create = AsyncMock(side_effect=FakeStatusError(500)) + llm = self._llm(create) + with self.assertRaises(FakeStatusError): + await llm.complete([{"role": "user", "content": "x"}], tools=[]) + self.assertLessEqual(create.await_count, 4) + self.assertGreater(create.await_count, 1) + + async def test_empty_choices_raises_clear_error(self): + create = AsyncMock(return_value=MagicMock(choices=[])) + llm = self._llm(create) + with self.assertRaisesRegex(RuntimeError, "choice"): + await llm.complete([{"role": "user", "content": "x"}], tools=[]) + + +class BoomLLM: + async def complete(self, messages, tools): + raise RuntimeError("boom-llm") + + +class TestLoopErrorBoundary(unittest.IsolatedAsyncioTestCase): + async def test_llm_failure_emits_error_and_persists_failed_turn(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + registry = ToolRegistry([]) + loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), BoomLLM()) + events = [event async for event in loop.stream_events("hi")] + kinds = [event["type"] for event in events] + self.assertIn("error", kinds) + error_event = next(event for event in events if event["type"] == "error") + self.assertIn("boom-llm", error_event["message"]) + self.assertEqual(events[-2]["type"], "done") + self.assertEqual(events[-1]["type"], "session") + active = index.active() + records = index.get(active["session_id"])["recent_turn_records"] + self.assertEqual(records[-1]["status"], "failed") + + +class TestSessionIndexDurability(unittest.TestCase): + def test_corrupt_sessions_file_is_backed_up_not_silently_replaced(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + index.create(idea="precious work", session_id="keep-me") + index.sessions_path.write_text("{ definitely not json", encoding="utf-8") + data = index.load() + self.assertEqual(data["sessions"], {}) + backups = list(index.vimax_dir.glob("sessions.json.corrupt-*")) + self.assertEqual(len(backups), 1, "corrupt state must be preserved for recovery, not discarded") + self.assertIn("definitely not json", backups[0].read_text(encoding="utf-8")) + + def test_save_is_atomic_and_leaves_no_temp_files(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + index.create(session_id="roundtrip") + self.assertEqual(list(index.vimax_dir.glob("*.tmp")), []) + self.assertIn("roundtrip", index.load()["sessions"]) + + def test_concurrent_creates_do_not_lose_sessions(self): + with tempfile.TemporaryDirectory() as tmp: + index_a = SessionIndex(tmp) + index_b = SessionIndex(tmp) + + def worker(index, tag): + for i in range(40): + index.create(session_id=f"s-{tag}-{i}") + + threads = [ + threading.Thread(target=worker, args=(index_a, "a")), + threading.Thread(target=worker, args=(index_b, "b")), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + sessions = index_a.load()["sessions"] + self.assertEqual(len(sessions), 80, "concurrent read-modify-write must not lose sessions") + + +class TestBoundedRetryPolicies(unittest.TestCase): + CASES = [ + ("Screenwriter.write_script_based_on_story", Screenwriter.write_script_based_on_story), + ("ScriptPlanner.plan_script", ScriptPlanner.plan_script), + ("RerankerBgeSiliconapi.__call__", RerankerBgeSiliconapi.__call__), + ("ImageGeneratorDoubaoSeedreamYunwuAPI.generate_single_image", ImageGeneratorDoubaoSeedreamYunwuAPI.generate_single_image), + ("ImageGeneratorNanobananaGoogleAPI.generate_single_image", ImageGeneratorNanobananaGoogleAPI.generate_single_image), + ("ImageGeneratorNanobananaYunwuAPI.generate_single_image", ImageGeneratorNanobananaYunwuAPI.generate_single_image), + ] + + def test_every_retry_is_bounded_with_backoff(self): + for name, fn in self.CASES: + with self.subTest(name=name): + retrying = getattr(fn, "retry", None) + self.assertIsNotNone(retrying, f"{name} must have a retry policy") + self.assertIsNot(retrying.stop, stop_never, f"{name} must not retry forever") + self.assertNotIsInstance(retrying.wait, wait_none, f"{name} must back off between attempts") + + +class _FakeResponse: + def __init__(self, payload, status=200): + self.payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def json(self): + return self.payload + + +class _FakeSession: + def __init__(self, scripted): + self.scripted = list(scripted) + self.calls = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def _next(self): + response = self.scripted[min(self.calls, len(self.scripted) - 1)] + self.calls += 1 + return _FakeResponse(*response) + + def post(self, url, **kwargs): + return self._next() + + def get(self, url, **kwargs): + return self._next() + + +class TestClientHttpErrors(unittest.IsolatedAsyncioTestCase): + async def test_reranker_surfaces_http_error_without_retry(self): + session = _FakeSession([ + ({"message": "invalid api key"}, 401), + ({"results": []}, 200), + ]) + reranker = RerankerBgeSiliconapi(api_key="bad", base_url="http://x") + with patch("tools.reranker_bge_silicon_api.aiohttp.ClientSession", return_value=session): + with self.assertRaisesRegex(RuntimeError, "401"): + await reranker(documents=["doc"], query="q", top_n=1) + self.assertEqual(session.calls, 1, "4xx must fail fast with the real error, not retry into KeyError") + + async def test_seedream_surfaces_http_error_without_retry(self): + session = _FakeSession([ + ({"error": {"message": "invalid api key"}}, 401), + ({"data": [{"url": "http://img"}]}, 200), + ]) + generator = ImageGeneratorDoubaoSeedreamYunwuAPI(api_key="bad") + with patch("tools.image_generator_doubao_seedream_yunwu_api.aiohttp.ClientSession", return_value=session): + with self.assertRaisesRegex(RuntimeError, "401"): + await generator.generate_single_image(prompt="p") + self.assertEqual(session.calls, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_script2video_pipeline_guards.py b/tests/test_script2video_pipeline_guards.py new file mode 100644 index 0000000..8e47cad --- /dev/null +++ b/tests/test_script2video_pipeline_guards.py @@ -0,0 +1,62 @@ +import tempfile +import unittest +from pathlib import Path + +from interfaces import Camera, ShotBriefDescription, ShotDescription +from pipelines.script2video_pipeline import Script2VideoPipeline, _group_shots_into_cameras + + +class FlakyCameraImageGenerator: + def __init__(self): + self.calls = 0 + + async def construct_camera_tree(self, cameras, shot_descs): + self.calls += 1 + if self.calls == 1: + return ["not-a-camera"] + return cameras + + +class Script2VideoPipelineGuardTests(unittest.IsolatedAsyncioTestCase): + def test_group_shots_into_cameras_does_not_use_camera_idx_as_list_index(self): + shots = [ + ShotDescription(idx=0, is_last=False, cam_idx=2, visual_desc="a", variation_type="small", variation_reason="same", ff_desc="a", ff_vis_char_idxs=[], lf_desc="a", lf_vis_char_idxs=[], motion_desc="a", audio_desc="none"), + ShotDescription(idx=1, is_last=True, cam_idx=5, visual_desc="b", variation_type="small", variation_reason="same", ff_desc="b", ff_vis_char_idxs=[], lf_desc="b", lf_vis_char_idxs=[], motion_desc="b", audio_desc="none"), + ShotDescription(idx=2, is_last=True, cam_idx=2, visual_desc="c", variation_type="small", variation_reason="same", ff_desc="c", ff_vis_char_idxs=[], lf_desc="c", lf_vis_char_idxs=[], motion_desc="c", audio_desc="none"), + ] + cameras = _group_shots_into_cameras(shots) + self.assertEqual([camera.idx for camera in cameras], [2, 5]) + self.assertEqual(cameras[0].active_shot_idxs, [0, 2]) + self.assertEqual(cameras[1].active_shot_idxs, [1]) + + async def test_plan_text_artifacts_retries_bad_camera_tree_schema(self): + with tempfile.TemporaryDirectory() as tmp: + pipeline = Script2VideoPipeline(chat_model=object(), image_generator=object(), video_generator=object(), working_dir=tmp) + pipeline.camera_image_generator = FlakyCameraImageGenerator() + + async def design_storyboard(script, characters, user_requirement, quiet=False): + return [{"idx": 0, "is_last": True, "cam_idx": 3, "visual_desc": "wide shot", "audio_desc": "waves"}] + + async def decompose_visual_descriptions(shot_brief_descriptions, characters, quiet=False): + return [{"idx": 0, "is_last": True, "cam_idx": 3, "visual_desc": "wide shot", "variation_type": "small", "variation_reason": "simple", "ff_desc": "start", "ff_vis_char_idxs": [], "lf_desc": "end", "lf_vis_char_idxs": [], "motion_desc": "walk", "audio_desc": "waves"}] + + pipeline.design_storyboard = design_storyboard + pipeline.decompose_visual_descriptions = decompose_visual_descriptions + events = [] + result = await pipeline.plan_text_artifacts( + "script", + "req", + "style", + characters=[{"idx": 0, "identifier_in_scene": "Man", "is_visible": True, "static_features": "adult", "dynamic_features": "coat"}], + progress=lambda stage, message, metadata=None: events.append(stage), + quiet=True, + ) + + self.assertEqual(pipeline.camera_image_generator.calls, 2) + self.assertIn("construct_camera_tree_retry", events) + self.assertEqual(result["camera_tree"][0].idx, 3) + self.assertTrue((Path(tmp) / "camera_tree.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vimax_adapters.py b/tests/test_vimax_adapters.py new file mode 100644 index 0000000..fca144b --- /dev/null +++ b/tests/test_vimax_adapters.py @@ -0,0 +1,452 @@ +import asyncio +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from interfaces import Camera, CharacterInScene, ShotBriefDescription, ShotDescription +from agent_runtime.session_index import SessionIndex +from agent_runtime.vimax_adapters import ViMaxAdapters +from agent_runtime.tools import ToolRuntimeContext +from pipelines.idea2video_pipeline import Idea2VideoPipeline +from pipelines.script2video_pipeline import Script2VideoPipeline + + +class FakeIdeaPipeline: + def __init__(self, chat_model, image_generator, video_generator, working_dir): + self.working_dir = Path(working_dir) + self.working_dir.mkdir(parents=True, exist_ok=True) + + async def develop_story(self, idea, user_requirement, quiet=False): + path = self.working_dir / "story.txt" + path.write_text("story", encoding="utf-8") + return "story" + + async def extract_characters(self, story, quiet=False): + chars = [CharacterInScene(idx=0, identifier_in_scene="Cat", is_visible=True, static_features="black cat", dynamic_features="helmet")] + (self.working_dir / "characters.json").write_text(json.dumps([c.model_dump() for c in chars]), encoding="utf-8") + return chars + + async def write_script_based_on_story(self, story, user_requirement, quiet=False): + script = [{"scene": "cat jumps"}] + (self.working_dir / "script.json").write_text(json.dumps(script), encoding="utf-8") + return script + + + + +class HangingIdeaPipeline(FakeIdeaPipeline): + async def develop_story(self, idea, user_requirement, quiet=False): + await asyncio.sleep(10) + return "story" + + + +class FakeRevisionModel: + async def ainvoke(self, prompt): + return SimpleNamespace(content='[{"idx": 0, "description": "more oppressive"}]') + + +class FailRenderIdeaPipeline(FakeIdeaPipeline): + async def __call__(self, idea, user_requirement, style, quiet=False): + raise RuntimeError("render failed") + + +class FailRender403IdeaPipeline(FakeIdeaPipeline): + async def __call__(self, idea, user_requirement, style, quiet=False): + raise RuntimeError("OpenRouter video create failed with HTTP 403: {'error': {'message': 'Key limit exceeded (total limit). Manage it using token sk-short', 'code': 403}}") + + +class NoisyRenderIdeaPipeline(FakeIdeaPipeline): + async def __call__(self, idea, user_requirement, style, quiet=False): + print("NOISE_FROM_RENDER_PIPELINE") + final = self.working_dir / "final_video.mp4" + final.write_text("video", encoding="utf-8") + return str(final) + + +class FakeScriptPipeline: + def __init__(self, chat_model, image_generator, video_generator, working_dir): + self.working_dir = Path(working_dir) + self.working_dir.mkdir(parents=True, exist_ok=True) + + async def plan_text_artifacts(self, script, user_requirement, style, characters=None, progress=None, quiet=False): + if progress: + progress("design_storyboard", "Designing storyboard", {}) + progress("decompose_shots", "Decomposing shot visual descriptions", {"shot_count": 1}) + progress("construct_camera_tree", "Constructing camera tree", {"shot_count": 1}) + (self.working_dir / "storyboard.json").write_text("[]", encoding="utf-8") + (self.working_dir / "camera_tree.json").write_text("[]", encoding="utf-8") + shot_dir = self.working_dir / "shots" / "0" + shot_dir.mkdir(parents=True, exist_ok=True) + (shot_dir / "shot_description.json").write_text("{}", encoding="utf-8") + if characters: + (self.working_dir / "characters.json").write_text(json.dumps([c.model_dump() for c in characters]), encoding="utf-8") + return {} + + + + +class FailingScriptPipeline(FakeScriptPipeline): + async def plan_text_artifacts(self, script, user_requirement, style, characters=None, progress=None, quiet=False): + if progress: + progress("design_storyboard", "Designing storyboard", {}) + raise RuntimeError("storyboard failed") + + +class FakeInitChatModel: + def __init__(self): + self.calls = [] + + def __call__(self, **kwargs): + self.calls.append(kwargs) + return object() + + +class Script2VideoPlanningProgressTests(unittest.IsolatedAsyncioTestCase): + async def test_plan_text_artifacts_emits_progress_in_order(self): + with tempfile.TemporaryDirectory() as tmp: + pipeline = Script2VideoPipeline(chat_model=object(), image_generator=object(), video_generator=object(), working_dir=tmp) + chars = [CharacterInScene(idx=0, identifier_in_scene="Cat", is_visible=True, static_features="black cat", dynamic_features="helmet")] + storyboard = [ShotBriefDescription(idx=0, is_last=True, cam_idx=0, visual_desc="cat jumps", audio_desc="wind")] + shot = ShotDescription(idx=0, is_last=True, cam_idx=0, visual_desc="cat jumps", variation_type="small", variation_reason="simple motion", ff_desc="cat starts", ff_vis_char_idxs=[0], lf_desc="cat lands", lf_vis_char_idxs=[0], motion_desc="cat jumps", audio_desc="wind") + camera = [Camera(idx=0, active_shot_idxs=[0])] + + async def design_storyboard(script, characters, user_requirement, quiet=False): + return storyboard + + async def decompose_visual_descriptions(shot_brief_descriptions, characters, quiet=False): + return [shot] + + async def construct_camera_tree(shot_descriptions, quiet=False): + return camera + + pipeline.design_storyboard = design_storyboard + pipeline.decompose_visual_descriptions = decompose_visual_descriptions + pipeline.construct_camera_tree = construct_camera_tree + events = [] + await pipeline.plan_text_artifacts("script", "req", "style", characters=chars, progress=lambda stage, message, metadata=None: events.append(stage)) + self.assertEqual(events, ["extract_characters", "design_storyboard", "decompose_shots", "construct_camera_tree"]) + + + async def test_idea_pipeline_quiet_suppresses_text_planning_prints(self): + with tempfile.TemporaryDirectory() as tmp: + pipeline = Idea2VideoPipeline(chat_model=object(), image_generator=object(), video_generator=object(), working_dir=tmp) + + async def develop_story(idea, user_requirement): + return "story" + + pipeline.screenwriter = SimpleNamespace(develop_story=develop_story) + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + result = await pipeline.develop_story("idea", "req", quiet=True) + self.assertEqual(result, "story") + self.assertEqual(stdout.getvalue(), "") + + +class ViMaxAdapterTests(unittest.IsolatedAsyncioTestCase): + def test_build_chat_model_uses_bounded_init_chat_model_kwargs(self): + fake = FakeInitChatModel() + with patch.dict("os.environ", { + "VIMAX_LLM_API_KEY": "test-key", + "VIMAX_LLM_MODEL": "test-model", + "VIMAX_LLM_BASE_URL": "https://example.invalid/v1", + "VIMAX_LLM_REQUEST_TIMEOUT_SECONDS": "12", + "VIMAX_NARRATIVE_MAX_TOKENS": "1234", + }), patch("agent_runtime.vimax_adapters.init_chat_model", fake): + from agent_runtime.vimax_adapters import _build_chat_model + + _build_chat_model() + + self.assertEqual(fake.calls[0]["model"], "test-model") + self.assertEqual(fake.calls[0]["base_url"], "https://example.invalid/v1") + self.assertEqual(fake.calls[0]["timeout"], 12.0) + self.assertEqual(fake.calls[0]["max_retries"], 0) + self.assertEqual(fake.calls[0]["max_completion_tokens"], 1234) + + + async def test_narrative_planning_uses_text_only_pipeline(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \ + patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + result = await adapter.vimax_narrative_planning({"idea": "moon cat", "user_requirement": "short", "style": "anime"}) + self.assertTrue(result.ok) + payload = json.loads(result.content) + self.assertTrue(payload["ready_for_render"]) + root = Path(tmp) / payload["working_dir"] + self.assertTrue((root / "idea2video" / "scene_0" / "storyboard.json").exists()) + self.assertTrue((root / "idea2video" / "scene_0" / "camera_tree.json").exists()) + self.assertTrue((root / "idea2video" / "scene_0" / "shots" / "0" / "shot_description.json").exists()) + self.assertFalse((root / "script2video" / "storyboard.json").exists()) + self.assertFalse((root / "script2video" / "final_video.mp4").exists()) + + + async def test_script_mode_persists_source_script_for_render(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + script = "A red ball rolls across a white table." + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + result = await adapter.vimax_narrative_planning({"script": script, "user_requirement": "one shot"}) + self.assertTrue(result.ok) + payload = json.loads(result.content) + root = Path(tmp) / payload["working_dir"] + self.assertEqual((root / "script2video" / "script.txt").read_text(encoding="utf-8"), script) + self.assertEqual(index.artifact_checklist(payload["session_id"])["script2video/script.txt"], True) + from agent_runtime.vimax_adapters import _load_script_text + self.assertEqual(_load_script_text(root), script) + + + async def test_narrative_planning_forwards_pipeline_progress(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + events = [] + runtime = ToolRuntimeContext("vimax_narrative_planning", "vimax_narrative_planning", turn_id="turn-test", progress_callback=events.append) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \ + patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + result = await adapter.vimax_narrative_planning({"idea": "moon cat"}, runtime) + self.assertTrue(result.ok) + stages = [event["progress"]["stage"] for event in events if event.get("type") == "tool_progress"] + self.assertIn("initializing_llm", stages) + self.assertIn("develop_story", stages) + self.assertIn("design_storyboard", stages) + self.assertIn("decompose_shots", stages) + self.assertIn("construct_camera_tree", stages) + + + async def test_plan_scene_failure_marks_session_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \ + patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FailingScriptPipeline): + result = await adapter.vimax_narrative_planning({"idea": "moon cat"}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "recoverable_planning_step_failed") + self.assertTrue(result.metadata["retryable"]) + session = index.active() + self.assertEqual(session["stage"], "error") + self.assertIn("storyboard failed", session["summary"]) + + + async def test_narrative_planning_timeout_marks_session_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + with patch.dict("os.environ", {"VIMAX_NARRATIVE_STEP_TIMEOUT_SECONDS": "0.01"}), \ + patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", HangingIdeaPipeline): + result = await adapter.vimax_narrative_planning({"idea": "moon cat"}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "recoverable_planning_step_failed") + session = index.active() + self.assertIsNotNone(session) + self.assertEqual(session["stage"], "error") + self.assertIn("timed out", session["summary"]) + + + + async def test_active_session_without_new_input_continues_existing_idea(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="moon cat", user_requirement="short", style="anime") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + result = await adapter.vimax_narrative_planning({}) + self.assertTrue(result.ok) + payload = json.loads(result.content) + self.assertEqual(payload["session_id"], record["session_id"]) + self.assertEqual(index.active()["session_id"], record["session_id"]) + + + async def test_active_session_continuation_preserves_existing_style(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="moon cat", user_requirement="short", style="anime") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + result = await adapter.vimax_narrative_planning({"session_id": record["session_id"]}) + self.assertTrue(result.ok) + self.assertEqual(index.get(record["session_id"])["style"], "anime") + + async def test_new_idea_creates_new_session_instead_of_reusing_active(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \ + patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + first = await adapter.vimax_narrative_planning({"idea": "moon cat"}) + second = await adapter.vimax_narrative_planning({"idea": "ocean robot"}) + self.assertNotEqual(json.loads(first.content)["session_id"], json.loads(second.content)["session_id"]) + + + async def test_explicit_session_with_different_idea_creates_new_session(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + old = index.create(idea="old cat") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \ + patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline): + result = await adapter.vimax_narrative_planning({"session_id": old["session_id"], "idea": "new robot"}) + self.assertTrue(result.ok) + payload = json.loads(result.content) + self.assertNotEqual(payload["session_id"], old["session_id"]) + self.assertEqual(index.get(payload["session_id"])["idea"], "new robot") + + async def test_revision_mode_rewrites_existing_artifact_and_logs(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x") + target = Path(tmp) / record["working_dir"] / "idea2video" / "scene_0" / "storyboard.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('[{"idx": 0, "description": "calm"}]', encoding="utf-8") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=FakeRevisionModel()): + result = await adapter.vimax_narrative_planning({"revision_target": "idea2video/scene_0/storyboard.json", "revision_instruction": "make it oppressive"}) + self.assertTrue(result.ok) + self.assertIn("more oppressive", target.read_text(encoding="utf-8")) + self.assertTrue((Path(tmp) / ".vimax" / "logs" / "revisions.jsonl").exists()) + self.assertTrue(index.get(record["session_id"])["stale"]["final_video"]) + + + async def test_revision_missing_instruction_marks_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x") + target = Path(tmp) / record["working_dir"] / "idea2video" / "scene_0" / "storyboard.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('[]', encoding="utf-8") + adapter = ViMaxAdapters(Path(tmp), index) + result = await adapter.vimax_narrative_planning({"revision_target": "idea2video/scene_0/storyboard.json"}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "missing_revision_instruction") + self.assertEqual(index.get(record["session_id"])["stage"], "error") + + + async def test_revision_missing_target_marks_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x") + adapter = ViMaxAdapters(Path(tmp), index) + result = await adapter.vimax_narrative_planning({"revision_target": "idea2video/scene_0/missing.json", "revision_instruction": "change it"}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "dependency_missing") + self.assertEqual(index.get(record["session_id"])["stage"], "error") + + async def test_render_setup_failure_marks_session_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x") + root = Path(tmp) / record["working_dir"] / "idea2video" + (root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True) + (root / "story.txt").write_text("story", encoding="utf-8") + (root / "characters.json").write_text("[]", encoding="utf-8") + (root / "script.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", side_effect=RuntimeError("missing key")): + result = await adapter.vimax_render_video({}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "render_failed") + self.assertIn("missing key", result.content) + self.assertEqual(index.get(record["session_id"])["stage"], "error") + + async def test_render_failure_marks_session_error(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x") + root = Path(tmp) / record["working_dir"] / "idea2video" + (root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True) + (root / "story.txt").write_text("story", encoding="utf-8") + (root / "characters.json").write_text("[]", encoding="utf-8") + (root / "script.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FailRenderIdeaPipeline): + result = await adapter.vimax_render_video({}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "render_failed") + self.assertIn("render failed", result.content) + self.assertEqual(index.get(record["session_id"])["stage"], "error") + status_path = Path(tmp) / record["working_dir"] / "render_status.json" + events_path = Path(tmp) / record["working_dir"] / "render_events.jsonl" + self.assertTrue(status_path.exists()) + self.assertTrue(events_path.exists()) + status = json.loads(status_path.read_text(encoding="utf-8")) + self.assertEqual(status["status"], "error") + self.assertEqual(status["error_type"], "render_failed") + + async def test_render_403_key_limit_is_non_retryable_and_sanitized(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x") + root = Path(tmp) / record["working_dir"] / "idea2video" + (root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True) + (root / "story.txt").write_text("story", encoding="utf-8") + (root / "characters.json").write_text("[]", encoding="utf-8") + (root / "script.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8") + adapter = ViMaxAdapters(Path(tmp), index) + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), \ + patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), \ + patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FailRender403IdeaPipeline): + result = await adapter.vimax_render_video({}) + self.assertFalse(result.ok) + self.assertFalse(result.metadata["retryable"]) + self.assertIn("", result.metadata["error"]) + self.assertNotIn("sk-short", result.metadata["error"]) + status = json.loads((Path(tmp) / record["working_dir"] / "render_status.json").read_text(encoding="utf-8")) + self.assertFalse(status["retryable"]) + self.assertNotIn("sk-short", status["error"]) + + + async def test_render_pipeline_stdout_is_suppressed(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + record = index.create(idea="x", style="anime") + root = Path(tmp) / record["working_dir"] / "idea2video" + (root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True) + (root / "story.txt").write_text("story", encoding="utf-8") + (root / "characters.json").write_text("[]", encoding="utf-8") + (root / "script.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8") + (root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8") + adapter = ViMaxAdapters(Path(tmp), index) + stdout = io.StringIO() + with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", NoisyRenderIdeaPipeline), contextlib.redirect_stdout(stdout): + result = await adapter.vimax_render_video({}) + self.assertTrue(result.ok) + self.assertNotIn("NOISE_FROM_RENDER_PIPELINE", stdout.getvalue()) + + async def test_render_dependency_missing(self): + with tempfile.TemporaryDirectory() as tmp: + index = SessionIndex(tmp) + index.create(idea="x") + adapter = ViMaxAdapters(Path(tmp), index) + result = await adapter.vimax_render_video({}) + self.assertFalse(result.ok) + self.assertEqual(result.metadata["error_type"], "dependency_missing") diff --git a/tests/test_wrong_output_guards.py b/tests/test_wrong_output_guards.py new file mode 100644 index 0000000..f558865 --- /dev/null +++ b/tests/test_wrong_output_guards.py @@ -0,0 +1,184 @@ +"""Regression tests for silent wrong-output bugs in the script2video render path.""" + +import asyncio +import os +import tempfile +import unittest +from unittest.mock import AsyncMock, MagicMock + +from agents.reference_image_selector import select_pairs_by_indices +from agents.storyboard_artist import validate_char_idxs +from interfaces.camera import Camera +from interfaces.shot_description import ShotDescription +from pipelines.script2video_pipeline import ( + Script2VideoPipeline, + _collect_priority_shot_idxs, + _group_shots_into_cameras, +) +from utils.text import safe_path_component + + +def _shot(idx, cam_idx, variation_type="small", ff_chars=None, lf_chars=None): + return ShotDescription( + idx=idx, + is_last=False, + cam_idx=cam_idx, + visual_desc=f"shot {idx}", + variation_type=variation_type, + variation_reason="r", + ff_desc=f"first frame {idx}", + ff_vis_char_idxs=ff_chars or [], + lf_desc=f"last frame {idx}", + lf_vis_char_idxs=lf_chars or [], + motion_desc="m", + audio_desc="a", + ) + + +class TestCameraGrouping(unittest.TestCase): + def test_out_of_order_camera_indices_group_correctly(self): + # Shot 0 uses camera 1, shot 1 uses camera 0, shot 2 uses camera 1 again. + shots = [_shot(0, cam_idx=1), _shot(1, cam_idx=0), _shot(2, cam_idx=1)] + cameras = _group_shots_into_cameras(shots) + by_idx = {camera.idx: camera for camera in cameras} + self.assertEqual(by_idx[1].active_shot_idxs, [0, 2]) + self.assertEqual(by_idx[0].active_shot_idxs, [1]) + + +class TestPriorityShotIdxs(unittest.TestCase): + def test_priorities_are_shot_indices_not_camera_indices(self): + # Camera 2 depends on shot 7 of camera 0: shot 7 must be prioritized. + camera_tree = [ + Camera(idx=0, active_shot_idxs=[7, 8]), + Camera(idx=2, active_shot_idxs=[9], parent_cam_idx=0, parent_shot_idx=7), + ] + self.assertEqual(_collect_priority_shot_idxs(camera_tree), [7]) + + +class TestEventDictsAreInstanceState(unittest.TestCase): + def _pipeline(self, working_dir): + return Script2VideoPipeline( + chat_model=MagicMock(), + image_generator=MagicMock(), + video_generator=MagicMock(), + working_dir=working_dir, + ) + + def test_two_pipelines_do_not_share_events(self): + with tempfile.TemporaryDirectory() as tmp: + p1 = self._pipeline(os.path.join(tmp, "a")) + p2 = self._pipeline(os.path.join(tmp, "b")) + p1.frame_events[0] = {"first_frame": asyncio.Event()} + p1.shot_desc_events[0] = asyncio.Event() + p1.character_portrait_events[0] = asyncio.Event() + self.assertEqual(p2.frame_events, {}) + self.assertEqual(p2.shot_desc_events, {}) + self.assertEqual(p2.character_portrait_events, {}) + + def test_no_class_level_mutable_event_dicts(self): + for name in ("frame_events", "shot_desc_events", "character_portrait_events"): + self.assertNotIsInstance( + Script2VideoPipeline.__dict__.get(name), dict, + f"{name} must not be shared class state", + ) + + +class TestResumeIncludesNewCameraReference(unittest.IsolatedAsyncioTestCase): + async def test_existing_new_camera_image_is_still_offered_to_selector(self): + with tempfile.TemporaryDirectory() as tmp: + pipeline = Script2VideoPipeline( + chat_model=MagicMock(), + image_generator=MagicMock(), + video_generator=MagicMock(), + working_dir=tmp, + ) + shots = [_shot(0, cam_idx=0), _shot(1, cam_idx=1)] + camera = Camera( + idx=1, active_shot_idxs=[1], + parent_cam_idx=0, parent_shot_idx=0, + missing_info="wrong background", + ) + parent_done = asyncio.Event() + parent_done.set() + pipeline.frame_events = { + 0: {"first_frame": parent_done}, + 1: {"first_frame": asyncio.Event()}, + } + + # Resume state: transition video and new-camera image already on disk. + shot_dir = os.path.join(tmp, "shots", "1") + os.makedirs(shot_dir, exist_ok=True) + new_camera_path = os.path.join(shot_dir, "new_camera_1.png") + open(os.path.join(shot_dir, "transition_video_from_shot_0.mp4"), "wb").close() + open(new_camera_path, "wb").close() + + selector = AsyncMock(return_value={"reference_image_path_and_text_pairs": [], "text_prompt": "p"}) + pipeline.reference_image_selector = MagicMock(select_reference_images_and_generate_prompt=selector) + fake_image = MagicMock() + pipeline.image_generator.generate_single_image = AsyncMock(return_value=fake_image) + + await pipeline.generate_frames_for_single_camera( + camera=camera, + shot_descriptions=shots, + characters=[], + character_portraits_registry={}, + priority_shot_idxs=[], + ) + + selector.assert_awaited_once() + offered = selector.await_args.kwargs["available_image_path_and_text_pairs"] + offered_paths = [pair[0] for pair in offered] + self.assertIn(new_camera_path, offered_paths, + "resumed runs must offer the new-camera reference image to the selector") + + +class TestCharIdxValidation(unittest.TestCase): + def test_valid_indices_pass(self): + validate_char_idxs([0, 1], 2, "ff_vis_char_idxs") + + def test_out_of_range_rejected(self): + with self.assertRaises(ValueError): + validate_char_idxs([0, 2], 2, "ff_vis_char_idxs") + + def test_negative_rejected(self): + with self.assertRaises(ValueError): + validate_char_idxs([-1], 2, "lf_vis_char_idxs") + + +class TestReferenceSelectorIndices(unittest.TestCase): + def test_valid_selection(self): + pairs = [("a.png", "a"), ("b.png", "b")] + self.assertEqual(select_pairs_by_indices(pairs, [1]), [("b.png", "b")]) + + def test_negative_index_rejected(self): + with self.assertRaises(ValueError): + select_pairs_by_indices([("a.png", "a")], [-1]) + + def test_out_of_range_rejected(self): + with self.assertRaises(ValueError): + select_pairs_by_indices([("a.png", "a")], [3]) + + +class TestSafePathComponent(unittest.TestCase): + def test_clean_names_unchanged(self): + self.assertEqual(safe_path_component("Alice"), "Alice") + self.assertEqual(safe_path_component("Bob_2"), "Bob_2") + + def test_unicode_names_preserved(self): + self.assertEqual(safe_path_component("李雷"), "李雷") + + def test_path_separators_removed(self): + self.assertNotIn("/", safe_path_component("a/b")) + self.assertNotIn("\\", safe_path_component("a\\b")) + + def test_traversal_neutralized(self): + cleaned = safe_path_component("../../etc/passwd") + self.assertNotIn("/", cleaned) + self.assertFalse(cleaned.startswith(".")) + + def test_empty_becomes_placeholder(self): + self.assertEqual(safe_path_component(""), "unnamed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..ad7d2b2 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,35 @@ +# rendering abstraction +from .protocols import ImageGenerator, VideoGenerator +from .render_backend import RenderBackend + +# image generators +from .image_generator_doubao_seedream_yunwu_api import ImageGeneratorDoubaoSeedreamYunwuAPI +from .image_generator_nanobanana_google_api import ImageGeneratorNanobananaGoogleAPI +from .image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI + +# reranker for rag +from .reranker_bge_silicon_api import RerankerBgeSiliconapi + +# video generators +from .video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI +from .video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI, VideoGeneratorOminiYunwuAPI +from .video_generator_openrouter_api import VideoGeneratorOpenRouterAPI +from .video_generator_veo_google_api import VideoGeneratorVeoGoogleAPI +from .video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI + + +__all__ = [ + "ImageGenerator", + "VideoGenerator", + "RenderBackend", + "ImageGeneratorDoubaoSeedreamYunwuAPI", + "ImageGeneratorNanobananaGoogleAPI", + "ImageGeneratorNanobananaYunwuAPI", + "RerankerBgeSiliconapi", + "VideoGeneratorDoubaoSeedanceYunwuAPI", + "VideoGeneratorOmniYunwuAPI", + "VideoGeneratorOminiYunwuAPI", + "VideoGeneratorOpenRouterAPI", + "VideoGeneratorVeoGoogleAPI", + "VideoGeneratorVeoYunwuAPI", +] diff --git a/tools/image_generator_doubao_seedream_yunwu_api.py b/tools/image_generator_doubao_seedream_yunwu_api.py new file mode 100644 index 0000000..41066cf --- /dev/null +++ b/tools/image_generator_doubao_seedream_yunwu_api.py @@ -0,0 +1,74 @@ +# https://yunwu.apifox.cn/api-347960869 + +import asyncio +import logging +import aiohttp +from typing import List, Optional +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential +from utils.retry import after_func +from utils.image import image_path_to_b64 +from interfaces.image_output import ImageOutput + + +class ImageGeneratorDoubaoSeedreamYunwuAPI: + def __init__( + self, + api_key: str, + model: str = "doubao-seedream-4-0-250828", + + ): + self.api_key = api_key + self.base_url = "https://yunwu.ai/v1/images/generations" + self.model = model + + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, max=30), + retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)), + reraise=True, + after=after_func, + ) + async def generate_single_image( + self, + prompt: str, + reference_image_paths: List[str] = [], + size: Optional[str] = None, + **kwargs, + ) -> ImageOutput: + """ + size: [1024x1024, 4096x4096] + """ + + logging.info(f"Calling {self.model} to generate image...") + + image = [ + image_path_to_b64(path, mime=True) for path in reference_image_paths + ] + + payload = { + "model": self.model, + "prompt": prompt, + "sequential_image_generation": "disabled", # "auto" or "disabled" + # "sequential_image_generation_options": { + # "max_images": 1 + # }, + "response_format": "url", + "size": size if size is not None else "1024x1024", + } + if len(image) > 0: + payload["image"] = image + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session: + async with session.post(self.base_url, json=payload, headers=headers) as response: + response_json = await response.json() + if response.status >= 400: + raise RuntimeError(f"Image generation failed with HTTP {response.status}: {response_json}") + + data = response_json['data'][0]['url'] + return ImageOutput(fmt="url", ext="png", data=data) diff --git a/tools/image_generator_nanobanana_google_api.py b/tools/image_generator_nanobanana_google_api.py new file mode 100644 index 0000000..0288c90 --- /dev/null +++ b/tools/image_generator_nanobanana_google_api.py @@ -0,0 +1,97 @@ +# https://ai.google.dev/gemini-api/docs/image-generation + +import logging +import asyncio +from PIL import Image +from typing import List, Optional +from google import genai +from google.genai import types +from google.genai.errors import ClientError +from tenacity import retry, stop_after_attempt, wait_exponential +from interfaces.image_output import ImageOutput +from tools.image_orientation import ensure_not_portrait, landscape_guard_requested +from tools.image_response import image_from_response_part +from utils.retry import after_func +from utils.rate_limiter import RateLimiter + + +class ImageGeneratorNanobananaGoogleAPI: + def __init__( + self, + api_key: str, + rate_limiter: Optional[RateLimiter] = None, + ): + self.model = "gemini-2.5-flash-image" + self.rate_limiter = rate_limiter + self.client = genai.Client( + api_key=api_key, + ) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), after=after_func, reraise=True) + async def generate_single_image( + self, + prompt: str, + reference_image_paths: List[str] = [], + aspect_ratio: Optional[str] = "16:9", + **kwargs, + ) -> ImageOutput: + + """ + aspect_ratio: The aspect ratio of the image. + """ + + logging.info(f"Calling {self.model} to generate image...") + + # Apply rate limiting if configured + if self.rate_limiter: + await self.rate_limiter.acquire() + + reference_images = [Image.open(path) for path in reference_image_paths] + + # Retry logic for rate limit errors + max_retries = 3 + retry_delay = 5 + + for attempt in range(max_retries): + try: + response = await self.client.aio.models.generate_content( + model=self.model, + contents=reference_images + [prompt], + config=types.GenerateContentConfig( + response_modalities=["IMAGE"], + image_config=types.ImageConfig( + aspect_ratio=aspect_ratio, + ), + ), + ) + break + except ClientError as e: + if e.status_code == 429 and attempt < max_retries - 1: + wait_time = retry_delay * (2 ** attempt) + logging.warning(f"Rate limit hit (429), retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})") + await asyncio.sleep(wait_time) + else: + raise + + image = None + text = "" + for part in response.candidates[0].content.parts: + if part.text is not None: + text += part.text + elif part.inline_data is not None: + image = image_from_response_part(part) + + if image is None: + logging.error(f"No image generated. The response text is: {text}") + raise ValueError("No image generated") + + if landscape_guard_requested( + size=kwargs.get("size"), + aspect_ratio=aspect_ratio, + enforce_landscape=kwargs.get("enforce_landscape", True), + allow_portrait=kwargs.get("allow_portrait", False), + ): + ensure_not_portrait(image) + + return ImageOutput(fmt="pil", ext="png", data=image) + diff --git a/tools/image_generator_nanobanana_yunwu_api.py b/tools/image_generator_nanobanana_yunwu_api.py new file mode 100644 index 0000000..19b0b42 --- /dev/null +++ b/tools/image_generator_nanobanana_yunwu_api.py @@ -0,0 +1,79 @@ +# https://ai.google.dev/gemini-api/docs/image-generation?hl=zh-cn + +import logging +from PIL import Image +from typing import List, Optional +from google import genai +from google.genai import types +from tenacity import retry, stop_after_attempt, wait_exponential +from interfaces.image_output import ImageOutput +from tools.image_orientation import ensure_not_portrait, landscape_guard_requested +from tools.image_response import image_from_response_part +from utils.retry import after_func + + +class ImageGeneratorNanobananaYunwuAPI: + def __init__( + self, + api_key: str, + model: str = "gemini-2.5-flash-image-preview", + base_url: str = "https://yunwu.ai", + ): + self.client = genai.Client( + api_key=api_key, + http_options=types.HttpOptions( + base_url=base_url.rstrip("/"), + api_version="v1beta", + ), + ) + self.model = model + + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), after=after_func, reraise=True) + async def generate_single_image( + self, + prompt: str, + reference_image_paths: List[str] = [], + aspect_ratio: Optional[str] = "16:9", + **kwargs, + ) -> ImageOutput: + """ + aspect_ratio: The aspect ratio of the image. + """ + + logging.info(f"Calling {self.model} to generate image...") + + reference_images = [Image.open(path) for path in reference_image_paths] + + response = await self.client.aio.models.generate_content( + model=self.model, + contents=reference_images + [prompt], + config=types.GenerateContentConfig( + response_modalities=["TEXT", "IMAGE"], + image_config=types.ImageConfig( + aspect_ratio=aspect_ratio, + ), + ), + ) + + image = None + text = "" + for part in response.candidates[0].content.parts: + if part.text is not None: + text += part.text + elif part.inline_data is not None: + image = image_from_response_part(part) + + if image is None: + logging.error(f"No image generated. The response text is: {text}") + raise ValueError(f"Error occurred while generating image.") + + if landscape_guard_requested( + size=kwargs.get("size"), + aspect_ratio=aspect_ratio, + enforce_landscape=kwargs.get("enforce_landscape", True), + allow_portrait=kwargs.get("allow_portrait", False), + ): + ensure_not_portrait(image) + + return ImageOutput(fmt="pil", ext="png", data=image) diff --git a/tools/image_orientation.py b/tools/image_orientation.py new file mode 100644 index 0000000..656979a --- /dev/null +++ b/tools/image_orientation.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +from typing import Any + +from PIL import Image + + +def landscape_guard_requested(*, size: Any = None, aspect_ratio: Any = None, enforce_landscape: Any = True, allow_portrait: Any = False) -> bool: + if bool(allow_portrait): + return False + if bool(enforce_landscape): + return True + parsed = _parse_size(size) + if parsed and parsed[0] > parsed[1]: + return True + parsed_ratio = _parse_size(aspect_ratio) + return bool(parsed_ratio and parsed_ratio[0] > parsed_ratio[1]) + + +def ensure_not_portrait(image: Image.Image, *, tolerance: float | None = None) -> None: + width, height = image.size + if width <= 0 or height <= 0: + return + threshold = tolerance if tolerance is not None else _portrait_tolerance() + if height > width * threshold: + raise ValueError(f"Generated image is portrait-oriented ({width}x{height}); retrying for a landscape frame") + + +def _portrait_tolerance() -> float: + raw = os.environ.get("VIMAX_IMAGE_PORTRAIT_RETRY_TOLERANCE", "1.05") + try: + return max(1.0, float(raw)) + except ValueError: + return 1.05 + + +def _parse_size(size: Any) -> tuple[int, int] | None: + if not isinstance(size, str): + return None + normalized = size.lower() + separator = "x" if "x" in normalized else ":" if ":" in normalized else "" + if not separator: + return None + left, right = normalized.split(separator, 1) + try: + width = int(left.strip()) + height = int(right.strip()) + except ValueError: + return None + if width <= 0 or height <= 0: + return None + return width, height diff --git a/tools/image_response.py b/tools/image_response.py new file mode 100644 index 0000000..3da4628 --- /dev/null +++ b/tools/image_response.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import base64 +from io import BytesIO +from typing import Any + +from PIL import Image + + +def image_from_response_part(part: Any) -> Image.Image | None: + inline_data = getattr(part, "inline_data", None) + if inline_data is None and isinstance(part, dict): + inline_data = part.get("inline_data") + if inline_data is None: + return None + + as_image = getattr(part, "as_image", None) + if callable(as_image): + image = as_image() + if isinstance(image, Image.Image): + return image + + data = _value(inline_data, "data") + if data is None: + return None + if isinstance(data, str): + if data.startswith("data:") and "," in data: + data = data.split(",", 1)[1] + data = base64.b64decode(data) + if isinstance(data, bytearray): + data = bytes(data) + if not isinstance(data, bytes): + return None + return Image.open(BytesIO(data)).convert("RGB") + + +def _value(obj: Any, key: str) -> Any: + if isinstance(obj, dict): + return obj.get(key) + return getattr(obj, key, None) diff --git a/tools/protocols.py b/tools/protocols.py new file mode 100644 index 0000000..fde9ea6 --- /dev/null +++ b/tools/protocols.py @@ -0,0 +1,35 @@ +"""Structural typing contracts for rendering backends. + +Any class that exposes the right method signatures satisfies these +protocols -- no inheritance required. Existing generators (Google, +Yunwu/Doubao, Yunwu/Veo) are already compliant by duck typing. +""" + +from typing import List, Protocol, runtime_checkable + +from interfaces.image_output import ImageOutput +from interfaces.video_output import VideoOutput + + +@runtime_checkable +class ImageGenerator(Protocol): + """Generates a single image from a text prompt and optional reference images.""" + + async def generate_single_image( + self, + prompt: str, + reference_image_paths: List[str], + **kwargs, + ) -> ImageOutput: ... + + +@runtime_checkable +class VideoGenerator(Protocol): + """Generates a single video from a text prompt and optional reference images.""" + + async def generate_single_video( + self, + prompt: str, + reference_image_paths: List[str], + **kwargs, + ) -> VideoOutput: ... diff --git a/tools/render_backend.py b/tools/render_backend.py new file mode 100644 index 0000000..5b33d30 --- /dev/null +++ b/tools/render_backend.py @@ -0,0 +1,62 @@ +"""RenderBackend: config-driven factory for image and video generators. + +Reads the ``image_generator`` and ``video_generator`` sections from a +ViMax YAML config, instantiates the concrete classes via *class_path*, +and wires up rate limiters. + +Usage:: + + backend = RenderBackend.from_config(config) + image = await backend.image_generator.generate_single_image(...) + video = await backend.video_generator.generate_single_video(...) +""" + +import importlib +import logging +from dataclasses import dataclass +from typing import Any, Dict + +from utils.rate_limiter import RateLimiter + + +@dataclass +class RenderBackend: + """Bundles an image generator and a video generator.""" + + image_generator: Any + video_generator: Any + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> "RenderBackend": + """Build a RenderBackend from a parsed YAML config dict. + + Rate limiters are created from ``max_requests_per_minute`` / + ``max_requests_per_day`` if present in each generator section. + """ + img_cfg = config["image_generator"] + vid_cfg = config["video_generator"] + + image_gen = _instantiate(img_cfg, _build_rate_limiter(img_cfg)) + video_gen = _instantiate(vid_cfg, _build_rate_limiter(vid_cfg)) + + logging.info("RenderBackend: image=%s, video=%s", + img_cfg["class_path"], vid_cfg["class_path"]) + + return cls(image_generator=image_gen, video_generator=video_gen) + + +def _build_rate_limiter(section: Dict[str, Any]) -> RateLimiter | None: + rpm = section.get("max_requests_per_minute") + rpd = section.get("max_requests_per_day") + if rpm or rpd: + return RateLimiter(max_requests_per_minute=rpm, max_requests_per_day=rpd) + return None + + +def _instantiate(section: Dict[str, Any], rate_limiter: RateLimiter | None) -> Any: + module_path, cls_name = section["class_path"].rsplit(".", 1) + cls = getattr(importlib.import_module(module_path), cls_name) + init_args = dict(section.get("init_args", {})) + if rate_limiter is not None: + init_args["rate_limiter"] = rate_limiter + return cls(**init_args) diff --git a/tools/reranker_bge_silicon_api.py b/tools/reranker_bge_silicon_api.py new file mode 100644 index 0000000..66533b5 --- /dev/null +++ b/tools/reranker_bge_silicon_api.py @@ -0,0 +1,83 @@ +from typing import List +import aiohttp +import asyncio +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential +import logging + + +class RerankerBgeSiliconapi: + def __init__( + self, + api_key: str, + base_url: str, + model: str = "BAAI/bge-reranker-v2-m3", + ): + self.api_key = api_key + self.base_url = base_url + self.model = model + # return_documents: bool = True, + + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, max=30), + retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)), + reraise=True, + after=lambda retry_state: logging.warning(f"Retrying SiliconReranker due to error: {retry_state.outcome.exception()}"), + ) + async def __call__( + self, + documents: List[str], + query: str, + top_n: int, + ) -> List[str]: + + url = f"{self.base_url}/rerank" + + payload = { + "model": self.model, + "query": query, + "documents": documents, + "top_n": top_n, + "return_documents": True, + } + + + headers = { + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as resp: + response = await resp.json() + if resp.status >= 400: + raise RuntimeError(f"Rerank request failed with HTTP {resp.status}: {response}") + + + """ + { + "id": "", + "results": [ + { + "document": { + "text": "" + }, + "index": 123, + "relevance_score": 123 + } + ], + "tokens": { + "input_tokens": 123, + "output_tokens": 123 + } + } + """ + + results = [] + + for result in response["results"]: + results.append((result["document"]["text"], result["relevance_score"])) + + return results \ No newline at end of file diff --git a/tools/video_generator_doubao_seedance_yunwu_api.py b/tools/video_generator_doubao_seedance_yunwu_api.py new file mode 100644 index 0000000..d77e815 --- /dev/null +++ b/tools/video_generator_doubao_seedance_yunwu_api.py @@ -0,0 +1,212 @@ +import logging +from typing import List, Literal +import asyncio +import aiohttp +from interfaces.video_output import VideoOutput +from utils.image import image_path_to_b64 + + +class VideoGeneratorDoubaoSeedanceYunwuAPI: + def __init__( + self, + api_key: str, + t2v_model: str = "doubao-seedance-1-0-lite-t2v-250428", + ff2v_model: str = "doubao-seedance-1-0-lite-i2v-250428", + flf2v_model: str = "doubao-seedance-1-0-lite-i2v-250428", + max_create_attempts: int = 3, + poll_interval: int = 2, + max_poll_attempts: int = 300, + ): + self.api_key = api_key + self.t2v_model = t2v_model + self.ff2v_model = ff2v_model + self.flf2v_model = flf2v_model + self.max_create_attempts = max_create_attempts + self.poll_interval = poll_interval + self.max_poll_attempts = max_poll_attempts + + + async def create_video_generation_task( + self, + prompt: str, + reference_image_paths: List[str], + resolution: Literal["480p", "720p", "1080p"] = "720p", + aspect_ratio: str = "16:9", + fps: Literal[16, 24] = 16, + duration: Literal[5, 10] = 5, + ) -> str: + """ + Create a video generation task and return the task ID. + + Args: + prompt: Text prompt for video generation + reference_image_paths: List of 1 or 2 reference images + + Returns: + Task ID string + """ + if len(reference_image_paths) == 0: + model = self.t2v_model + elif len(reference_image_paths) == 1: + model = self.ff2v_model + elif len(reference_image_paths) == 2: + model = self.flf2v_model + else: + raise ValueError("reference_image_paths must contain 1 or 2 images.") + + logging.info(f"Calling {model} to generate video...") + + url = "https://yunwu.ai/volc/v1/contents/generations/tasks" + + + content = [ + { + "type": "text", + "text": prompt + f" --rs {resolution} --rt {aspect_ratio} --dur {duration} --fps {fps} --wm false --seed -1 --cf false" + } + ] + if len(reference_image_paths) >= 1: + content.append( + { + "type": "image_url", + "image_url": { + "url": image_path_to_b64(reference_image_paths[0]) + }, + "role": "first_frame", + } + ) + if len(reference_image_paths) >= 2: + content.append( + { + "type": "image_url", + "image_url": { + "url": image_path_to_b64(reference_image_paths[1]) + }, + "role": "last_frame", + } + ) + + payload = { + "model": model, + "content": content + } + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + last_error = None + for attempt in range(1, self.max_create_attempts + 1): + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as response: + response_json = await response.json() + http_status = response.status + logging.debug(f"Response: {response_json}") + except Exception as e: + last_error = e + logging.error(f"Error occurred while creating video generation task (attempt {attempt}/{self.max_create_attempts}): {e}") + if attempt < self.max_create_attempts: + await asyncio.sleep(attempt) + continue + + if http_status >= 400: + message = f"Video generation task creation failed with HTTP {http_status}: {response_json}" + if http_status < 500: + raise RuntimeError(message) + last_error = RuntimeError(message) + logging.error(f"{message} (attempt {attempt}/{self.max_create_attempts})") + if attempt < self.max_create_attempts: + await asyncio.sleep(attempt) + continue + + task_id = response_json.get("id") + if not task_id: + raise RuntimeError(f"Video generation task creation returned no task id: {response_json}") + logging.info(f"Video generation task created successfully. Task ID: {task_id}") + return task_id + + raise RuntimeError(f"Failed to create video generation task after {self.max_create_attempts} attempts.") from last_error + + async def query_video_generation_task( + self, + task_id: str, + ) -> str: + """ + Query the video generation task until completion and return the video URL. + + Args: + task_id: Task ID to query + + Returns: + Video URL string + """ + url = f"https://yunwu.ai/volc/v1/contents/generations/tasks/{task_id}" + headers = { + 'Authorization': f'Bearer {self.api_key}', + } + + attempts = 0 + consecutive_errors = 0 + while True: + if attempts >= self.max_poll_attempts: + raise TimeoutError(f"Video generation did not complete after {attempts} polls.") + attempts += 1 + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + response_json = await response.json() + http_status = response.status + except Exception as e: + consecutive_errors += 1 + if consecutive_errors >= 5: + raise RuntimeError(f"Querying video generation task failed {consecutive_errors} times in a row.") from e + logging.error(f"Error occurred while querying video generation task: {e}. Retrying in {self.poll_interval} seconds...") + await asyncio.sleep(self.poll_interval) + continue + consecutive_errors = 0 + + if http_status >= 400: + raise RuntimeError(f"Querying video generation task failed with HTTP {http_status}: {response_json}") + + status = response_json.get("status") + if status == "succeeded": + video_url = response_json["content"]["video_url"] + logging.info(f"Video generation completed successfully. Video URL: {video_url}") + return video_url + elif status == "failed": + logging.error(f"Video generation failed. Response: {response_json}") + raise ValueError("Video generation failed.") + else: + logging.info(f"Video generation is still in progress. Checking again in {self.poll_interval} seconds...") + await asyncio.sleep(self.poll_interval) + + async def generate_single_video( + self, + prompt: str, + reference_image_paths: List[str], + resolution: Literal["480p", "720p", "1080p"] = "720p", + aspect_ratio: str = "16:9", + fps: Literal[16, 24] = 16, + duration: Literal[5, 10] = 5, + **kwargs, + ) -> VideoOutput: + """ + Generate a single video by creating a task and waiting for completion. + + Args: + prompt: Text prompt for video generation + reference_image_paths: List of 1 or 2 reference images + resolution: Resolution of the video + aspect_ratio: Aspect ratio of the video + fps: Frames per second of the video + duration: Duration of the video + Returns: + VideoOutput containing the video URL + """ + task_id = await self.create_video_generation_task(prompt, reference_image_paths, resolution, aspect_ratio, fps, duration) + video_url = await self.query_video_generation_task(task_id) + return VideoOutput(fmt="url", ext="mp4", data=video_url) + diff --git a/tools/video_generator_omni_yunwu_api.py b/tools/video_generator_omni_yunwu_api.py new file mode 100644 index 0000000..92e1451 --- /dev/null +++ b/tools/video_generator_omni_yunwu_api.py @@ -0,0 +1,224 @@ +import asyncio +import logging +from typing import List, Optional + +import aiohttp + +from interfaces.video_output import VideoOutput +from utils.image import image_path_to_b64 +from utils.rate_limiter import RateLimiter + + +class VideoGeneratorOmniYunwuAPI: + def __init__( + self, + api_key: str, + t2v_model: str = "omni-flash", + i2v_model: str = "omni-flash", + base_url: str = "https://yunwu.ai", + seconds: int = 8, + enable_upsample: bool = False, + enable_sample: Optional[bool] = None, + poll_interval: int = 2, + max_poll_attempts: Optional[int] = 300, + max_create_attempts: int = 3, + rate_limiter: Optional[RateLimiter] = None, + ): + self.api_key = api_key + self.t2v_model = t2v_model + self.i2v_model = i2v_model + self.base_url = base_url.rstrip("/") + self.seconds = seconds + self.enable_upsample = enable_upsample + self.enable_sample = enable_sample + self.poll_interval = poll_interval + self.max_poll_attempts = max_poll_attempts + self.max_create_attempts = max_create_attempts + self.rate_limiter = rate_limiter + + def _headers(self) -> dict: + return { + "Accept": "application/json", + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + def _image_uri(self, image_path: str) -> str: + if image_path.startswith(("http://", "https://", "data:")): + return image_path + return image_path_to_b64(image_path, mime=True) + + def _build_payload( + self, + prompt: str, + reference_image_paths: List[str], + aspect_ratio: str, + seconds: Optional[int], + size: Optional[str], + enable_upsample: Optional[bool], + enable_sample: Optional[bool], + ) -> dict: + if len(reference_image_paths) > 3: + raise ValueError("The number of reference images must be no more than 3") + + payload = { + "model": self.t2v_model if len(reference_image_paths) == 0 else self.i2v_model, + "prompt": prompt, + "seconds": str(seconds or self.seconds), + } + + if len(reference_image_paths) == 0: + payload["type"] = 1 + elif len(reference_image_paths) <= 2: + payload["type"] = 2 + payload["images"] = [self._image_uri(path) for path in reference_image_paths] + else: + payload["type"] = 3 + payload["images"] = [self._image_uri(path) for path in reference_image_paths] + + if aspect_ratio: + payload["aspect_ratio"] = aspect_ratio + if size: + payload["size"] = size + if enable_upsample is not None: + payload["enable_upsample"] = enable_upsample + if enable_sample is not None: + payload["enable_sample"] = enable_sample + + return payload + + async def create_video_generation_task( + self, + prompt: str, + reference_image_paths: List[str], + aspect_ratio: str = "16:9", + seconds: Optional[int] = None, + size: Optional[str] = None, + enable_upsample: Optional[bool] = None, + enable_sample: Optional[bool] = None, + ) -> tuple[str, str]: + payload = self._build_payload( + prompt=prompt, + reference_image_paths=reference_image_paths, + aspect_ratio=aspect_ratio, + seconds=seconds, + size=size, + enable_upsample=self.enable_upsample if enable_upsample is None else enable_upsample, + enable_sample=self.enable_sample if enable_sample is None else enable_sample, + ) + + logging.info("Calling %s to generate video...", payload["model"]) + + if self.rate_limiter: + await self.rate_limiter.acquire() + + url = f"{self.base_url}/v1/video/create" + last_error = None + for attempt in range(1, self.max_create_attempts + 1): + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=self._headers(), json=payload) as response: + response_json = await response.json() + http_status = response.status + logging.debug("Response: %s", response_json) + except Exception as e: + last_error = e + logging.error( + "Error occurred while creating video generation task (attempt %s/%s): %s", + attempt, + self.max_create_attempts, + e, + ) + if attempt < self.max_create_attempts: + await asyncio.sleep(attempt) + continue + + if http_status >= 400: + message = f"Video generation task creation failed with HTTP {http_status}: {response_json}" + if http_status < 500: + raise RuntimeError(message) + last_error = RuntimeError(message) + logging.error("%s (attempt %s/%s)", message, attempt, self.max_create_attempts) + if attempt < self.max_create_attempts: + await asyncio.sleep(attempt) + continue + + task_id = response_json.get("id") + if not task_id: + raise RuntimeError(f"Video generation task creation returned no task id: {response_json}") + logging.info("Video generation task created successfully. Task ID: %s", task_id) + return task_id, payload["model"] + + raise RuntimeError( + f"Failed to create video generation task after {self.max_create_attempts} attempts." + ) from last_error + + async def query_video_generation_task(self, task_id: str, model: str) -> str: + url = f"{self.base_url}/v1/video/query" + params = {"id": task_id, "model": model} + + attempts = 0 + while True: + if self.max_poll_attempts is not None and attempts >= self.max_poll_attempts: + raise TimeoutError(f"Video generation did not complete after {attempts} polls.") + attempts += 1 + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=self._headers(), params=params) as response: + response_json = await response.json() + logging.debug("Response: %s", response_json) + except Exception as e: + logging.error( + "Error occurred while querying video generation task: %s. Retrying in %s seconds...", + e, + self.poll_interval, + ) + await asyncio.sleep(self.poll_interval) + continue + + status = response_json.get("status") + if status == "completed": + detail = response_json.get("detail") or {} + video_url = ( + response_json.get("video_url") + or detail.get("upsample_video_url") + or detail.get("video_url") + ) + if not video_url: + raise RuntimeError(f"Video generation completed without a video URL: {response_json}") + logging.info("Video generation completed successfully. Video URL: %s", video_url) + return video_url + + if status in {"failed", "error"}: + raise RuntimeError(f"Video generation failed: {response_json}") + + logging.info("Video generation status: %s, waiting %s seconds...", status, self.poll_interval) + await asyncio.sleep(self.poll_interval) + + async def generate_single_video( + self, + prompt: str, + reference_image_paths: List[str], + aspect_ratio: str = "16:9", + seconds: Optional[int] = None, + size: Optional[str] = None, + enable_upsample: Optional[bool] = None, + enable_sample: Optional[bool] = None, + **kwargs, + ) -> VideoOutput: + task_id, model = await self.create_video_generation_task( + prompt=prompt, + reference_image_paths=reference_image_paths, + aspect_ratio=aspect_ratio, + seconds=seconds, + size=size, + enable_upsample=enable_upsample, + enable_sample=enable_sample, + ) + video_url = await self.query_video_generation_task(task_id, model) + return VideoOutput(fmt="url", ext="mp4", data=video_url) + + +class VideoGeneratorOminiYunwuAPI(VideoGeneratorOmniYunwuAPI): + """Backward-compatible alias for the common "omini" spelling.""" diff --git a/tools/video_generator_openrouter_api.py b/tools/video_generator_openrouter_api.py new file mode 100644 index 0000000..5ee21a2 --- /dev/null +++ b/tools/video_generator_openrouter_api.py @@ -0,0 +1,201 @@ +import asyncio +import logging +import os +from typing import List +from urllib.parse import urljoin + +import aiohttp + +from interfaces.video_output import VideoOutput +from utils.image import image_path_to_b64 + + +def _env_int(name: str, default: int) -> int: + try: + return max(0, int(os.environ.get(name, str(default)))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return max(0.0, float(os.environ.get(name, str(default)))) + except ValueError: + return default + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _emit_progress(progress, stage: str, message: str, metadata: dict | None = None) -> None: + if progress is not None: + progress(stage, message, metadata or {}) + + +class VideoGeneratorOpenRouterAPI: + def __init__( + self, + api_key: str, + model: str = "google/veo-3.1-lite", + base_url: str = "https://openrouter.ai/api/v1", + http_referer: str = "", + app_title: str = "ViMax", + ): + self.api_key = api_key + self.model = model + self.base_url = base_url.rstrip("/") + self.http_referer = http_referer + self.app_title = app_title + + async def generate_single_video( + self, + prompt: str = "", + reference_image_paths: List[str] = [], + aspect_ratio: str = "16:9", + **kwargs, + ) -> VideoOutput: + progress = kwargs.get("progress") + request_timeout_seconds = _env_float("VIMAX_VIDEO_REQUEST_TIMEOUT_SECONDS", 60.0) + query_timeout_seconds = _env_float("VIMAX_VIDEO_QUERY_TIMEOUT_SECONDS", 600.0) + poll_interval_seconds = _env_float("VIMAX_VIDEO_POLL_INTERVAL_SECONDS", 10.0) + duration = _env_int("VIMAX_OPENROUTER_VIDEO_DURATION", 8) + resolution = os.environ.get("VIMAX_OPENROUTER_VIDEO_RESOLUTION", "720p") + generate_audio = _env_bool("VIMAX_OPENROUTER_GENERATE_AUDIO", True) + + payload = { + "model": self.model, + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "duration": duration, + "resolution": resolution, + "generate_audio": generate_audio, + } + frame_images = _frame_images(reference_image_paths) + if frame_images: + payload["frame_images"] = frame_images + + headers = self._headers() + timeout = aiohttp.ClientTimeout(total=request_timeout_seconds) + _emit_progress(progress, "video_create", f"Creating OpenRouter video generation task with {self.model}", {"model": self.model, "duration": duration, "resolution": resolution, "frame_count": len(frame_images)}) + + create_status, create_payload = await _post_json( + f"{self.base_url}/videos", + headers=headers, + payload=payload, + timeout=timeout, + hard_timeout_seconds=request_timeout_seconds, + ) + if create_status >= 400: + raise RuntimeError(f"OpenRouter video create failed with HTTP {create_status}: {create_payload}") + job_id = create_payload.get("id") + polling_url = create_payload.get("polling_url") + if not job_id or not polling_url: + raise RuntimeError(f"OpenRouter video create response missing id or polling_url: {create_payload}") + _emit_progress(progress, "video_task_created", "OpenRouter video generation task created", {"model": self.model, "job_id": job_id, "status": create_payload.get("status")}) + + poll_url = _absolute_url(self.base_url, polling_url) + deadline = asyncio.get_running_loop().time() + query_timeout_seconds if query_timeout_seconds > 0 else None + last_status = create_payload.get("status") + last_payload = create_payload + while deadline is None or asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(poll_interval_seconds) + poll_status, poll_payload = await _get_json( + poll_url, + headers=headers, + timeout=timeout, + hard_timeout_seconds=request_timeout_seconds, + ) + if poll_status >= 400: + raise RuntimeError(f"OpenRouter video poll failed with HTTP {poll_status}: {poll_payload}") + last_payload = poll_payload + status = poll_payload.get("status") + last_status = status + _emit_progress(progress, "video_status", f"OpenRouter video generation status: {status}", {"model": self.model, "job_id": job_id, "status": status}) + + if status == "completed": + urls = poll_payload.get("unsigned_urls") or [] + if urls: + content_url = urls[0] + else: + content_url = f"{self.base_url}/videos/{job_id}/content?index=0" + _emit_progress(progress, "video_download_start", "Downloading OpenRouter video output", {"model": self.model, "job_id": job_id}) + download_status, data = await _get_bytes( + content_url, + headers=headers if _needs_authorization(content_url) else {}, + timeout=timeout, + hard_timeout_seconds=request_timeout_seconds, + ) + if download_status >= 400: + raise RuntimeError(f"OpenRouter video content download failed with HTTP {download_status}: {data[:500]!r}") + _emit_progress(progress, "video_completed", "OpenRouter video generation completed and downloaded", {"model": self.model, "job_id": job_id}) + return VideoOutput(fmt="bytes", ext="mp4", data=data) + if status in {"failed", "cancelled", "expired"}: + raise RuntimeError(f"OpenRouter video generation {status} for job {job_id}: {poll_payload.get('error') or poll_payload}") + + raise RuntimeError(f"OpenRouter video generation timed out after {query_timeout_seconds:g}s for job {job_id}; last_status={last_status}; last_payload={last_payload}") + + def _headers(self) -> dict[str, str]: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + if self.http_referer: + headers["HTTP-Referer"] = self.http_referer + if self.app_title: + headers["X-OpenRouter-Title"] = self.app_title + return headers + + +def _frame_images(reference_image_paths: List[str]) -> list[dict]: + if len(reference_image_paths) > 2: + raise ValueError("OpenRouter video generation supports at most first and last frame images") + frame_types = ["first_frame", "last_frame"] + return [ + { + "type": "image_url", + "image_url": {"url": image_path_to_b64(path, mime=True)}, + "frame_type": frame_types[index], + } + for index, path in enumerate(reference_image_paths) + ] + + +def _absolute_url(base_url: str, url: str) -> str: + if url.startswith("http://") or url.startswith("https://"): + return url + return urljoin(f"{base_url.rstrip('/')}/", url.lstrip("/")) + + +def _needs_authorization(url: str) -> bool: + return url.startswith("https://openrouter.ai/api/") + + +async def _post_json(url: str, *, headers: dict[str, str], payload: dict, timeout: aiohttp.ClientTimeout, hard_timeout_seconds: float) -> tuple[int, dict]: + async def request() -> tuple[int, dict]: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, json=payload) as response: + return response.status, await response.json(content_type=None) + + return await asyncio.wait_for(request(), timeout=hard_timeout_seconds + 5) + + +async def _get_json(url: str, *, headers: dict[str, str], timeout: aiohttp.ClientTimeout, hard_timeout_seconds: float) -> tuple[int, dict]: + async def request() -> tuple[int, dict]: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=headers) as response: + return response.status, await response.json(content_type=None) + + return await asyncio.wait_for(request(), timeout=hard_timeout_seconds + 5) + + +async def _get_bytes(url: str, *, headers: dict[str, str], timeout: aiohttp.ClientTimeout, hard_timeout_seconds: float) -> tuple[int, bytes]: + async def request() -> tuple[int, bytes]: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=headers) as response: + return response.status, await response.read() + + return await asyncio.wait_for(request(), timeout=hard_timeout_seconds + 5) diff --git a/tools/video_generator_veo_google_api.py b/tools/video_generator_veo_google_api.py new file mode 100644 index 0000000..a44e2eb --- /dev/null +++ b/tools/video_generator_veo_google_api.py @@ -0,0 +1,116 @@ +import logging +from typing import List, Optional +import asyncio +from google import genai +from google.genai import types +from google.genai.errors import ClientError +from interfaces.video_output import VideoOutput +from utils.rate_limiter import RateLimiter + +# https://ai.google.dev/gemini-api/docs/video-generation?hl=zh-cn + + +class VideoGeneratorVeoGoogleAPI: + def __init__( + self, + api_key: str, + t2v_model: str = "veo-3.1-generate-preview", + ff2v_model: str = "veo-3.1-generate-preview", + flf2v_model: str = "veo-3.1-generate-preview", + rate_limiter: Optional[RateLimiter] = None, + ): + self.api_key = api_key + self.t2v_model = t2v_model + self.ff2v_model = ff2v_model + self.flf2v_model = flf2v_model + self.rate_limiter = rate_limiter + + self.client = genai.Client( + api_key=api_key, + ) + + async def generate_single_video( + self, + prompt: str, + reference_image_paths: List[str], + resolution: str = "1080p", + aspect_ratio: str = "16:9", + duration: int = 8, + **kwargs, + ) -> VideoOutput: + + params = { + "prompt": prompt, + } + config_params = { + "resolution": resolution, + "aspect_ratio": aspect_ratio, + "duration_seconds": duration, + } + if len(reference_image_paths) == 0: + params["model"] = self.t2v_model + elif len(reference_image_paths) == 1: + params["model"] = self.ff2v_model + params["image"] = types.Image.from_file(location=reference_image_paths[0]) + elif len(reference_image_paths) == 2: + params["model"] = self.flf2v_model + params["image"] = types.Image.from_file(location=reference_image_paths[0]) + config_params["last_frame"] = types.Image.from_file(location=reference_image_paths[1]) + else: + raise ValueError("The number of reference images must be no more than 2") + + logging.info(f"Calling {params['model']} to generate video...") + + # Apply rate limiting if configured + if self.rate_limiter: + await self.rate_limiter.acquire() + + # Retry logic for rate limit errors + max_retries = 3 + retry_delay = 5 + + for attempt in range(max_retries): + try: + operation = self.client.models.generate_videos( + **params, + config=types.GenerateVideosConfig(**config_params), + ) + break + except ClientError as e: + if e.status_code == 429 and attempt < max_retries - 1: + wait_time = retry_delay * (2 ** attempt) + logging.warning(f"Rate limit hit (429), retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})") + await asyncio.sleep(wait_time) + else: + raise + + while not operation.done: + await asyncio.sleep(2) + operation = self.client.operations.get(operation) + logging.info(f"Video generation not completed, waiting 2 seconds...") + + # Check if operation completed successfully + if operation.error: + error_msg = f"Video generation failed: {operation.error}" + logging.error(error_msg) + raise RuntimeError(error_msg) + + if not operation.response: + error_msg = "Video generation completed but no response received" + logging.error(error_msg) + raise RuntimeError(error_msg) + + if not hasattr(operation.response, 'generated_videos') or not operation.response.generated_videos: + error_msg = "Video generation completed but no videos were generated" + logging.error(error_msg) + raise RuntimeError(error_msg) + + generated_video = operation.response.generated_videos[0] + self.client.files.download(file=generated_video.video) + + video_output = VideoOutput( + fmt="bytes", + ext="mp4", + data=generated_video.video.video_bytes, + ) + return video_output diff --git a/tools/video_generator_veo_yunwu_api.py b/tools/video_generator_veo_yunwu_api.py new file mode 100644 index 0000000..30cfe04 --- /dev/null +++ b/tools/video_generator_veo_yunwu_api.py @@ -0,0 +1,177 @@ +import logging +from typing import List, Optional +from PIL import Image +import asyncio +import aiohttp +import os +from interfaces.video_output import VideoOutput +from utils.image import image_path_to_b64 + + +def _env_int(name: str, default: int) -> int: + try: + return max(0, int(os.environ.get(name, str(default)))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return max(0.0, float(os.environ.get(name, str(default)))) + except ValueError: + return default + + +def _emit_progress(progress, stage: str, message: str, metadata: dict | None = None) -> None: + if progress is not None: + progress(stage, message, metadata or {}) + + +class VideoGeneratorVeoYunwuAPI: + def __init__( + self, + api_key: str, + t2v_model: str = "veo3.1-fast", # text to video + ff2v_model: str = "veo3.1-fast", # first frame to video + flf2v_model: str = "veo2-fast-frames", # first and last frame to video + base_url: str = "https://yunwu.ai", + ): + """ + all models: + veo2 + veo2-fast + veo2-fast-frames + veo2-fast-components + veo2-pro + veo3 + veo3-fast + veo3-pro + veo3-pro-frames + veo3-fast-frames + veo3-frames + + NOTE: veo3 does not support first and last frame to video generation. + """ + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.t2v_model = t2v_model + self.ff2v_model = ff2v_model + self.flf2v_model = flf2v_model + + async def generate_single_video( + self, + prompt: str = "", + reference_image_paths: List[Image.Image] = [], + aspect_ratio: str = "16:9", + **kwargs, + ) -> VideoOutput: + progress = kwargs.get("progress") + create_retries = _env_int("VIMAX_VIDEO_CREATE_RETRIES", 3) + query_timeout_seconds = _env_float("VIMAX_VIDEO_QUERY_TIMEOUT_SECONDS", 600.0) + request_timeout_seconds = _env_float("VIMAX_VIDEO_REQUEST_TIMEOUT_SECONDS", 60.0) + poll_interval_seconds = _env_float("VIMAX_VIDEO_POLL_INTERVAL_SECONDS", 5.0) + max_query_errors = _env_int("VIMAX_VIDEO_MAX_QUERY_ERRORS", 5) + if len(reference_image_paths) == 0: + model = self.t2v_model + elif len(reference_image_paths) == 1: + model = self.ff2v_model + elif len(reference_image_paths) == 2: + model = self.flf2v_model + else: + raise ValueError("The number of reference images must be no more than 2") + + logging.info(f"Calling {model} to generate video...") + + # 1. Create video generation task + payload = { + "prompt": prompt, + "model": model, + "images": [image_path_to_b64(image_path, mime=True) for image_path in reference_image_paths], + "enhance_prompt": True, + } + # only veo3 supports aspect ratio setting + if model.startswith("veo3"): + payload["aspect_ratio"] = aspect_ratio + + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + url = f"{self.base_url}/v1/video/create" + task_id = None + last_create_error = None + timeout = aiohttp.ClientTimeout(total=request_timeout_seconds) + for attempt in range(1, create_retries + 1): + try: + _emit_progress(progress, "video_create", f"Creating video generation task with {model}", {"model": model, "attempt": attempt, "max_attempts": create_retries}) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, json=payload) as response: + response_payload = await response.json(content_type=None) + logging.debug(f"Response: {response_payload}") + if response.status >= 400: + raise RuntimeError(f"Video create failed with HTTP {response.status}: {response_payload}") + task_id = response_payload.get("id") + if not task_id: + raise RuntimeError(f"Video create response missing id: {response_payload}") + logging.info(f"Video generation task created successfully. Task ID: {task_id}") + _emit_progress(progress, "video_task_created", "Video generation task created", {"model": model, "task_id": task_id}) + break + except Exception as e: + last_create_error = e + logging.error(f"Error occurred while creating video generation task: {e}.") + _emit_progress(progress, "video_create_error", f"Video create attempt {attempt} failed", {"model": model, "attempt": attempt, "error": str(e)}) + if attempt < create_retries: + await asyncio.sleep(1) + if not task_id: + raise RuntimeError(f"Video create failed after {create_retries} attempts: {last_create_error}") + + + # 2. Query the video generation task until the video generation is completed + headers = { + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.api_key}', + } + + deadline = asyncio.get_running_loop().time() + query_timeout_seconds if query_timeout_seconds > 0 else None + query_errors = 0 + last_status = None + while deadline is None or asyncio.get_running_loop().time() < deadline: + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(f"{self.base_url}/v1/video/query?id={task_id}", headers=headers) as response: + payload = await response.json(content_type=None) + logging.debug(f"Response: {payload}") + if response.status >= 400: + raise RuntimeError(f"Video query failed with HTTP {response.status}: {payload}") + status = payload.get("status") + if not status: + raise RuntimeError(f"Video query response missing status: {payload}") + query_errors = 0 + except Exception as e: + query_errors += 1 + logging.error(f"Error occurred while querying video generation task: {e}.") + _emit_progress(progress, "video_query_error", "Video query failed", {"model": model, "task_id": task_id, "error": str(e), "query_errors": query_errors, "max_query_errors": max_query_errors}) + if query_errors >= max_query_errors: + raise RuntimeError(f"Video query failed {query_errors} times for task {task_id}: {e}") + await asyncio.sleep(poll_interval_seconds) + continue + + if status == "completed": + logging.info(f"Video generation completed successfully") + video_url = payload.get("video_url") + if not video_url: + raise RuntimeError(f"Video task completed without video_url: {payload}") + _emit_progress(progress, "video_completed", "Video generation completed", {"model": model, "task_id": task_id}) + return VideoOutput(fmt="url", ext="mp4", data=video_url) + elif status == "failed": + logging.error(f"Video generation failed: \n{payload}") + raise RuntimeError(f"Video generation failed for task {task_id}: {payload}") + else: + logging.info(f"Video generation status: {status}, waiting 1 second...") + last_status = status + _emit_progress(progress, "video_status", f"Video generation status: {status}", {"model": model, "task_id": task_id, "status": status}) + await asyncio.sleep(poll_interval_seconds) + continue + raise RuntimeError(f"Video generation timed out after {query_timeout_seconds:g}s for task {task_id}; last_status={last_status}") diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..a158669 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1196 @@ +{ + "name": "vimax-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vimax-ui", + "version": "0.1.0", + "dependencies": { + "ink": "^4.4.1", + "ink-text-input": "^5.0.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "tsx": "^4.20.5", + "typescript": "^5.6.3" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.0.tgz", + "integrity": "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz", + "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^6.0.0", + "auto-bind": "^5.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^3.1.0", + "code-excerpt": "^4.0.0", + "indent-string": "^5.0.0", + "is-ci": "^3.0.1", + "is-lower-case": "^2.0.2", + "is-upper-case": "^2.0.2", + "lodash": "^4.17.21", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^6.0.0", + "stack-utils": "^2.0.6", + "string-width": "^5.1.2", + "type-fest": "^0.12.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0", + "ws": "^8.12.0", + "yoga-wasm-web": "~0.3.3" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-5.0.1.tgz", + "integrity": "sha512-crnsYJalG4EhneOFnr/q+Kzw1RgmXI2KsBaLFE6mpiIKxAtJLUnvygOF2IUKO8z4nwkSkveGRBMd81RoYdRSag==", + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0", + "type-fest": "^3.6.1" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": "^4.0.0", + "react": "^18.0.0" + } + }, + "node_modules/ink-text-input/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", + "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/is-upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz", + "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-6.0.0.tgz", + "integrity": "sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz", + "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-wasm-web": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", + "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==", + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..3800ed6 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "vimax-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "tui": "tsx src/cli.tsx", + "test": "tsx src/lineMapping.test.ts && tsx src/slashCommands.test.ts && tsx src/workspaceMeta.test.ts" + }, + "dependencies": { + "ink": "^4.4.1", + "ink-text-input": "^5.0.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "tsx": "^4.20.5", + "typescript": "^5.6.3" + } +} diff --git a/ui/src/cli.tsx b/ui/src/cli.tsx new file mode 100644 index 0000000..f441ee9 --- /dev/null +++ b/ui/src/cli.tsx @@ -0,0 +1,601 @@ +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {render, Box, Text, useApp, useInput, useStdout} from 'ink'; +import stringWidth from 'string-width'; +import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {applyStreamEvent, createMappingState} from './lineMapping.js'; +import {matchingSlashCommands, shouldShowSlashCommands} from './slashCommands.js'; +import {compactionLabel, compactTargetFromEnv, resolveWorkspacePath, type WorkspaceMeta} from './workspaceMeta.js'; +import type {MappingState, StreamEvent, WorkspaceLine} from './types.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..'); + +const THINKING_FRAMES = ['', '.', '..', '...']; + +const WORKSPACE_BORDER_COLORS = ['blue', 'blueBright', 'cyan', 'blueBright', 'blue']; + +type CliOptions = { + agentArgs: string[]; +}; + +const cliOptions = parseCliArgs(process.argv.slice(2)); + +function parseCliArgs(argv: string[]): CliOptions { + const agentArgs: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--new-session') { + agentArgs.push('--new-session'); + continue; + } + if (arg === '--session') { + const sessionId = argv[index + 1]; + if (!sessionId) throw new Error('--session requires a session id'); + agentArgs.push('--session', sessionId); + index += 1; + continue; + } + if (arg === '--help' || arg === '-h') { + printHelpAndExit(); + } + throw new Error(`Unknown TUI argument: ${arg}`); + } + return {agentArgs}; +} + +function printHelpAndExit(): never { + console.log(`Usage: + ./vimax tui + ./vimax tui new + ./vimax tui resume [session_id] + +Direct TUI args: + --new-session create and activate a new empty session + --session activate an existing session`); + process.exit(0); +} + +function gradientColor(index: number, total: number): string { + if (total <= 1) return WORKSPACE_BORDER_COLORS[0] ?? 'blue'; + const scaled = (index / (total - 1)) * (WORKSPACE_BORDER_COLORS.length - 1); + return WORKSPACE_BORDER_COLORS[Math.min(WORKSPACE_BORDER_COLORS.length - 1, Math.max(0, Math.round(scaled)))] ?? 'blue'; +} + +function useThinkingFrame(active: boolean): string { + const [frame, setFrame] = useState(0); + useEffect(() => { + if (!active) { + setFrame(0); + return; + } + const timer = setInterval(() => setFrame((value) => (value + 1) % THINKING_FRAMES.length), 220); + return () => clearInterval(timer); + }, [active]); + return THINKING_FRAMES[frame]; +} + +function useTerminalWidth(stdout: NodeJS.WriteStream): number { + const [terminal, setTerminal] = useState({width: Math.max(20, stdout.columns || 100), revision: 0}); + useEffect(() => { + let resizeTimer: NodeJS.Timeout | null = null; + const redraw = (clear: boolean) => { + if (clear) { + // Ink does not always erase cells from the previous frame when the + // terminal is resized quickly. Clear only after resize, then force a + // render even when the new width equals the previous width. + stdout.write('\u001b[2J\u001b[3J\u001b[H'); + } + setTerminal((current) => ({width: Math.max(20, stdout.columns || 100), revision: current.revision + 1})); + }; + const update = () => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => redraw(true), 60); + }; + redraw(false); + stdout.on('resize', update); + return () => { + if (resizeTimer) clearTimeout(resizeTimer); + stdout.off('resize', update); + }; + }, [stdout]); + return terminal.width; +} + + +function baseAgentArgs(): string[] { + return ['main_agent.py', '--jsonl', '--stdin-repl', ...cliOptions.agentArgs]; +} + +function agentCommand(): {command: string; args: string[]} { + if (process.env.VIMAX_AGENT_COMMAND) { + return {command: process.env.VIMAX_AGENT_COMMAND, args: splitArgs(process.env.VIMAX_AGENT_ARGS ?? '')}; + } + if (process.env.VIMAX_PYTHON_CMD) { + return {command: process.env.VIMAX_PYTHON_CMD, args: baseAgentArgs()}; + } + const bundledUv = process.env.VIMAX_UV_CMD ?? '/home/xavierhuang/.local/bin/uv'; + if (existsSync(bundledUv)) { + return {command: bundledUv, args: ['run', 'python', ...baseAgentArgs()]}; + } + const venvPython = path.join(repoRoot, '.venv', 'bin', 'python3'); + if (existsSync(venvPython)) { + return {command: venvPython, args: baseAgentArgs()}; + } + return {command: 'uv', args: ['run', 'python', ...baseAgentArgs()]}; +} + + +function splitArgs(value: string): string[] { + return value.split(/\s+/).map((part) => part.trim()).filter(Boolean); +} + +function App() { + const {exit} = useApp(); + const {stdout} = useStdout(); + const terminalWidth = useTerminalWidth(stdout); + const [lines, setLines] = useState([]); + const [input, setInput] = useState(''); + const [cursor, setCursor] = useState(0); + const inputRef = useRef(''); + const cursorRef = useRef(0); + const [busy, setBusy] = useState(false); + const [activityText, setActivityText] = useState('ViMax thinking'); + const [workspaceMeta, setWorkspaceMeta] = useState({ + workspacePath: '.working_dir', + sessionId: '', + stage: '', + compactionUsed: 0, + compactionTarget: compactTargetFromEnv(process.env), + }); + const stateRef = useRef(createMappingState()); + const childRef = useRef(null); + const bufferRef = useRef(''); + const responseIdleTimerRef = useRef(null); + + const width = useMemo(() => Math.max(20, terminalWidth - 6), [terminalWidth]); + const thinkingFrame = useThinkingFrame(busy); + const slashMatches = useMemo(() => matchingSlashCommands(input), [input]); + const showSlashPopup = shouldShowSlashCommands(input, busy); + + useEffect(() => { + inputRef.current = input; + const length = Array.from(input).length; + if (cursorRef.current > length) { + cursorRef.current = length; + setCursor(length); + } + }, [input]); + + useEffect(() => { + cursorRef.current = cursor; + }, [cursor]); + + function updateInput(next: string, nextCursor: number) { + const length = Array.from(next).length; + const boundedCursor = Math.max(0, Math.min(nextCursor, length)); + inputRef.current = next; + cursorRef.current = boundedCursor; + setInput(next); + setCursor(boundedCursor); + } + + useInput((value, key) => { + if (key.ctrl && value === 'c') { + childRef.current?.kill(); + exit(); + return; + } + if (busy) return; + const currentChars = Array.from(inputRef.current); + const currentCursor = Math.max(0, Math.min(cursorRef.current, currentChars.length)); + if (key.leftArrow) { + updateInput(inputRef.current, currentCursor - 1); + return; + } + if (key.rightArrow) { + updateInput(inputRef.current, currentCursor + 1); + return; + } + if ((key as {home?: boolean}).home) { + updateInput(inputRef.current, 0); + return; + } + if ((key as {end?: boolean}).end) { + updateInput(inputRef.current, currentChars.length); + return; + } + if (value.includes('\r') || value.includes('\n')) { + const [beforeBreak] = value.split(/[\r\n]/, 1); + const pasted = Array.from(beforeBreak ?? ''); + const next = [...currentChars.slice(0, currentCursor), ...pasted, ...currentChars.slice(currentCursor)].join(''); + submit(next); + return; + } + if (key.return) { + submit(inputRef.current); + return; + } + const isBackspace = key.backspace || value === '\u007f' || value === '\b' || value === '\u001b\u007f'; + const isDelete = key.delete || value === '\u001b[3~' || value === '\u001b[P'; + if (isBackspace) { + if (currentCursor === 0) return; + const next = [...currentChars.slice(0, currentCursor - 1), ...currentChars.slice(currentCursor)].join(''); + updateInput(next, currentCursor - 1); + return; + } + if (isDelete) { + if (currentCursor < currentChars.length) { + const next = [...currentChars.slice(0, currentCursor), ...currentChars.slice(currentCursor + 1)].join(''); + updateInput(next, currentCursor); + return; + } + if (currentCursor > 0) { + const next = [...currentChars.slice(0, currentCursor - 1), ...currentChars.slice(currentCursor)].join(''); + updateInput(next, currentCursor - 1); + } + return; + } + if (!key.ctrl && !key.meta && value) { + const inserted = Array.from(value); + const next = [...currentChars.slice(0, currentCursor), ...inserted, ...currentChars.slice(currentCursor)].join(''); + updateInput(next, currentCursor + inserted.length); + } + }); + + useEffect(() => { + const {command, args} = agentCommand(); + const child = spawn(command, args, {cwd: repoRoot, env: process.env}); + childRef.current = child; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + bufferRef.current += chunk; + const parts = bufferRef.current.split('\n'); + bufferRef.current = parts.pop() ?? ''; + for (const part of parts) { + consumeJsonLine(part); + } + }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + for (const line of chunk.split('\n')) { + if (!line.trim()) continue; + appendLine({kind: 'terminal', text: `[stderr]: ${line}`}); + } + }); + + child.on('error', (error) => { + appendLine({kind: 'error', text: `agent process error: ${error.message}`}); + }); + + child.on('exit', (code, signal) => { + childRef.current = null; + setBusy(false); + if (code && code !== 0) { + appendLine({kind: 'error', text: `agent process exited with code ${code}`}); + } else if (signal) { + appendLine({kind: 'status', text: `agent process stopped by ${signal}`}); + } + }); + + return () => { + if (responseIdleTimerRef.current) clearTimeout(responseIdleTimerRef.current); + child.kill(); + }; + }, []); + + function appendLine(line: WorkspaceLine) { + stateRef.current = createMappingState(); + setLines((current) => [...current, line]); + } + + function stripThinking(lines: WorkspaceLine[]): WorkspaceLine[] { + return lines.filter((line) => line.kind !== 'thinking'); + } + + function consumeJsonLine(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + let event: StreamEvent; + try { + event = JSON.parse(trimmed) as StreamEvent; + } catch (error) { + appendLine({kind: 'error', text: `invalid JSONL event: ${trimmed}`}); + return; + } + updateWorkspaceMeta(event); + updateActivity(event); + if (event.type === 'done' || event.type === 'error' || event.type === 'session') { + clearResponseIdleTimer(); + setBusy(false); + } + setLines((current) => { + const mapped = applyStreamEvent(stripThinking(current), stateRef.current, event); + stateRef.current = mapped.state; + return mapped.lines; + }); + } + + function clearResponseIdleTimer() { + if (!responseIdleTimerRef.current) return; + clearTimeout(responseIdleTimerRef.current); + responseIdleTimerRef.current = null; + } + + function scheduleResponseIdleClear() { + clearResponseIdleTimer(); + responseIdleTimerRef.current = setTimeout(() => { + responseIdleTimerRef.current = null; + setBusy(false); + }, 1500); + } + + function updateActivity(event: StreamEvent) { + if (event.type === 'tool_start') { + clearResponseIdleTimer(); + setActivityText(`tool ${event.tool?.name ?? 'unknown'} running`); + return; + } + if (event.type === 'tool_progress') { + clearResponseIdleTimer(); + const stage = event.progress?.stage; + setActivityText(stage ? `tool ${event.tool?.name ?? 'unknown'}: ${stage}` : `tool ${event.tool?.name ?? 'unknown'} running`); + return; + } + if (event.type === 'tool_result') { + clearResponseIdleTimer(); + setActivityText('ViMax thinking'); + return; + } + if (event.type === 'token') { + setActivityText('ViMax responding'); + scheduleResponseIdleClear(); + return; + } + if (event.type === 'status') { + clearResponseIdleTimer(); + setActivityText(statusActivityLabel(event.phase, event.message)); + return; + } + if (event.type === 'done' || event.type === 'error' || event.type === 'session') { + clearResponseIdleTimer(); + setActivityText('ViMax thinking'); + return; + } + if (event.type === 'turn') { + clearResponseIdleTimer(); + setActivityText('ViMax thinking'); + } + } + + function updateWorkspaceMeta(event: StreamEvent) { + if (event.type === 'prompt_trace') { + const used = event.prompt_trace?.totals?.total_tokens ?? event.prompt_trace?.totals?.total_estimated_tokens ?? event.prompt_trace?.total_estimated_tokens; + if (typeof used === 'number' && Number.isFinite(used)) { + setWorkspaceMeta((current) => { + const nextUsed = Math.max(0, Math.round(used)); + const currentPercent = current.compactionTarget > 0 ? Math.round((current.compactionUsed / current.compactionTarget) * 100) : 0; + const nextPercent = current.compactionTarget > 0 ? Math.round((nextUsed / current.compactionTarget) * 100) : 0; + if (currentPercent === nextPercent && Math.abs(nextUsed - current.compactionUsed) < 100) return current; + return {...current, compactionUsed: nextUsed}; + }); + } + return; + } + if (event.type === 'session') { + const session = event.session?.session; + if (!session) return; + setWorkspaceMeta((current) => ({ + ...current, + workspacePath: resolveWorkspacePath(repoRoot, session.working_dir), + sessionId: session.session_id ?? current.sessionId, + stage: session.stage ?? current.stage, + })); + } + } + + function submit(value: string) { + const prompt = value.trim(); + if (!prompt || busy) return; + const child = childRef.current; + if (!child || child.killed || !child.stdin.writable) { + appendLine({kind: 'error', text: 'agent process is not available'}); + return; + } + setLines((current) => [...stripThinking(current), {kind: 'user', text: prompt}]); + clearResponseIdleTimer(); + setActivityText('ViMax thinking'); + stateRef.current = createMappingState(); + updateInput('', 0); + setBusy(true); + child.stdin.write(`${prompt}\n`); + } + + return ( + + + {showSlashPopup && } + + {busy ? '· ' : '› '} + + + + ); +} + + +function InputText({value, cursor, busy}: {value: string; cursor: number; busy: boolean}) { + const chars = Array.from(value); + const boundedCursor = Math.max(0, Math.min(cursor, chars.length)); + const before = chars.slice(0, boundedCursor).join(''); + const current = chars[boundedCursor] ?? ' '; + const after = chars.slice(boundedCursor + 1).join(''); + if (busy) { + return {value}; + } + return ( + + {before} + {current} + {after} + + ); +} + +function SlashCommandPopup({matches, width}: {matches: ReturnType; width: number}) { + const panelWidth = Math.max(20, width); + const visibleMatches = matches.slice(0, 6); + return ( + + {visibleMatches.length > 0 ? ( + visibleMatches.map((command) => ( + + {command.matchedPrefix} + {command.unmatchedSuffix} + {command.description} + + )) + ) : ( + No matching slash commands + )} + + ); +} + +function WorkspacePanel({lines, width, thinkingFrame, meta, busy, activityText}: {lines: WorkspaceLine[]; width: number; thinkingFrame: string; meta: WorkspaceMeta; busy: boolean; activityText: string}) { + const panelWidth = Math.max(20, width); + const contentWidth = Math.max(1, panelWidth - 4); + return ( + + + + {workspaceHeaderLines(meta, contentWidth).map((line, index) => ( + + ))} + {lines.flatMap((line, index) => { + const rawText = `› ${line.text}`; + return wrapText(rawText, contentWidth).map((part, partIndex) => ( + + )); + })} + {busy && wrapText(`› ${activityText}${thinkingFrame}`, contentWidth).map((part, index) => ( + + ))} + + + ); +} + +function workspaceHeaderLines(meta: WorkspaceMeta, width: number): Array<{text: string; color: string}> { + const rows: Array<{text: string; color: string}> = []; + for (const part of wrapText(`Path: ${meta.workspacePath}`, width)) { + rows.push({text: part, color: 'gray'}); + } + const session = [meta.sessionId, displayStage(meta.stage)].filter(Boolean).join(' · '); + if (session) { + for (const part of wrapText(`Session: ${session}`, width)) { + rows.push({text: part, color: 'gray'}); + } + } + for (const part of wrapText(compactionLabel(meta.compactionUsed, meta.compactionTarget), width)) { + rows.push({text: part, color: 'cyanBright'}); + } + return rows; +} + +function statusActivityLabel(phase: string | undefined, message: string | undefined): string { + if (phase === 'compact') return 'compacting context'; + if (phase === 'sampling_assistant') return 'ViMax thinking'; + if (phase === 'executing_tools') return 'running tools'; + const normalized = String(message ?? '').trim(); + return normalized || 'ViMax thinking'; +} + +function displayStage(stage: string): string { + const labels: Record = { + created: 'Created', + narrative_planning: 'Planning text', + narrative_planned: 'Text planned', + novel_planning: 'Planning novel', + novel_planned: 'Novel planned', + rendering: 'Rendering', + rendered: 'Rendered', + error: 'Error', + }; + return labels[stage] ?? stage.replace(/_/g, ' '); +} + +function GradientBorderLine({left, fill, right, width}: {left: string; fill: string; right: string; width: number}) { + const fillWidth = Math.max(0, width - 2); + return ( + + {left} + {Array.from({length: fillWidth}, (_, index) => ( + {fill} + ))} + {right} + + ); +} + +function WorkspaceContentLine({text, color, width}: {text: string; color: string; width: number}) { + const contentWidth = Math.max(1, width - 4); + const padding = Math.max(0, contentWidth - stringWidth(text)); + return ( + + + + {text} + {' '.repeat(padding)} + + + + ); +} + +function wrapText(text: string, width: number): string[] { + if (width <= 0) return [text]; + const rows: string[] = []; + for (const segment of text.split(/\r?\n/)) { + let current = ''; + let currentWidth = 0; + for (const char of Array.from(segment)) { + const charWidth = stringWidth(char); + if (current && currentWidth + charWidth > width) { + rows.push(current); + current = char; + currentWidth = charWidth; + } else { + current += char; + currentWidth += charWidth; + } + } + rows.push(current); + } + return rows; +} + +function lineColor(line: WorkspaceLine): string { + if (line.kind === 'user') return 'yellow'; + if (line.kind === 'assistant') return 'white'; + if (line.kind === 'thinking') return 'cyanBright'; + if (line.kind === 'terminal') return 'cyan'; + if (line.kind === 'error') return 'red'; + if (line.kind === 'tool' && line.status === 'error') return 'red'; + if (line.kind === 'tool') return 'magenta'; + return 'gray'; +} + +function clearTerminalForTuiStart() { + if (process.env.VIMAX_TUI_NO_CLEAR === '1') return; + if (!process.stdout.isTTY) return; + process.stdout.write('\u001b[2J\u001b[3J\u001b[H'); +} + +clearTerminalForTuiStart(); +render(); diff --git a/ui/src/lineMapping.test.ts b/ui/src/lineMapping.test.ts new file mode 100644 index 0000000..eec5b32 --- /dev/null +++ b/ui/src/lineMapping.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import {applyStreamEvent, createMappingState} from './lineMapping.js'; +import type {MappingState, WorkspaceLine} from './types.js'; + +let lines: WorkspaceLine[] = []; +let state: MappingState = createMappingState(); + +lines = [...lines, {kind: 'user', text: 'start'}]; +assert.deepEqual(lines[0], {kind: 'user', text: 'start'}); + +({lines, state} = applyStreamEvent(lines, state, {type: 'token', delta: 'hello'})); +({lines, state} = applyStreamEvent(lines, state, {type: 'token', delta: ' world'})); +assert.equal(lines.length, 2); +assert.deepEqual(lines[1], {kind: 'assistant', text: 'hello world'}); + +({lines, state} = applyStreamEvent(lines, state, {type: 'tool_start', tool: {name: 'vimax_narrative_planning'}})); +({lines, state} = applyStreamEvent(lines, state, {type: 'tool_progress', tool: {name: 'vimax_narrative_planning'}, progress: {stage: 'running', message: 'planning'}})); +({lines, state} = applyStreamEvent(lines, state, {type: 'tool_result', tool_result: {name: 'vimax_narrative_planning', ok: true}})); +assert.equal(lines.at(-3)?.kind, 'tool'); +assert.equal(lines.at(-2)?.kind, 'tool'); +assert.deepEqual(lines.at(-1), {kind: 'tool', status: 'done', text: 'tool vimax_narrative_planning done'}); + +({lines, state} = applyStreamEvent(lines, state, {type: 'tool_result', tool_result: {name: 'vimax_narrative_planning', ok: false, content: 'Developing story failed: Request timed out.'}})); +assert.deepEqual(lines.at(-1), {kind: 'tool', status: 'error', text: 'tool vimax_narrative_planning error: Developing story failed: Request timed out.'}); + +({lines, state} = applyStreamEvent(lines, state, {type: 'terminal', stream: 'stdout', line: 'render log'})); +assert.deepEqual(lines.at(-1), {kind: 'terminal', text: '[stdout]: render log'}); + +const lengthBeforeInternalEvents = lines.length; +({lines, state} = applyStreamEvent(lines, state, {type: 'turn', turn_id: 'turn-1'})); +({lines, state} = applyStreamEvent(lines, state, {type: 'status', phase: 'sampling_assistant', message: 'Sampling assistant'})); +({lines, state} = applyStreamEvent(lines, state, {type: 'session', session: {active_session_id: 's1', session: {session_id: 's1', stage: 'narrative_planned', working_dir: '.working_dir/s1'}}})); +assert.equal(lines.length, lengthBeforeInternalEvents); + +({lines, state} = applyStreamEvent(lines, state, {type: 'error', message: 'bad'})); +assert.deepEqual(lines.at(-1), {kind: 'error', text: 'bad'}); + +({lines, state} = applyStreamEvent(lines, state, {type: 'done', assistant: 'hello world'})); +assert.equal(state.assistantStreaming, false); + +console.log('lineMapping tests passed'); diff --git a/ui/src/lineMapping.ts b/ui/src/lineMapping.ts new file mode 100644 index 0000000..d2c097e --- /dev/null +++ b/ui/src/lineMapping.ts @@ -0,0 +1,70 @@ +import type {MappingState, StreamEvent, WorkspaceLine} from './types.js'; + +export function createMappingState(): MappingState { + return {assistantStreaming: false}; +} + +export function appendUserLine(lines: WorkspaceLine[], text: string): {lines: WorkspaceLine[]; state: MappingState} { + return {lines: [...lines, {kind: 'user', text}], state: createMappingState()}; +} + +export function applyStreamEvent(lines: WorkspaceLine[], state: MappingState, event: StreamEvent): {lines: WorkspaceLine[]; state: MappingState} { + switch (event.type) { + case 'turn': + case 'status': + return {lines, state}; + case 'token': + return appendAssistantToken(lines, state, event.delta ?? ''); + case 'tool_start': + return append(lines, state, {kind: 'tool', status: 'running', text: `tool ${event.tool?.name ?? 'unknown'} started`}); + case 'tool_progress': + return append(lines, state, { + kind: 'tool', + status: 'running', + text: compactJoin([`tool ${event.tool?.name ?? 'unknown'}`, event.progress?.stage, event.progress?.message]), + }); + case 'tool_result': { + const result = event.tool_result ?? {}; + const ok = result.ok !== false; + const name = result.name ?? 'unknown'; + const detail = ok ? '' : cleanToolError(result.content); + return append(lines, state, {kind: 'tool', status: ok ? 'done' : 'error', text: detail ? `tool ${name} error: ${detail}` : `tool ${name} ${ok ? 'done' : 'error'}`}); + } + case 'terminal': + return append(lines, state, {kind: 'terminal', text: compactJoin([event.stream ? `[${event.stream}]` : '', event.line])}); + case 'session': + return {lines, state}; + case 'done': + return {lines, state: createMappingState()}; + case 'error': + return append(lines, state, {kind: 'error', text: event.message ?? 'Unknown error'}); + default: + return {lines, state}; + } +} + +function append(lines: WorkspaceLine[], state: MappingState, line: WorkspaceLine): {lines: WorkspaceLine[]; state: MappingState} { + return {lines: [...lines, line], state: {...state, assistantStreaming: false}}; +} + +function appendAssistantToken(lines: WorkspaceLine[], state: MappingState, delta: string): {lines: WorkspaceLine[]; state: MappingState} { + if (!delta) return {lines, state: {...state, assistantStreaming: true}}; + const next = [...lines]; + const last = next[next.length - 1]; + if (state.assistantStreaming && last?.kind === 'assistant') { + next[next.length - 1] = {...last, text: `${last.text}${delta}`}; + } else { + next.push({kind: 'assistant', text: delta}); + } + return {lines: next, state: {assistantStreaming: true}}; +} + + +function compactJoin(parts: Array): string { + return parts.map((part) => String(part ?? '').trim()).filter(Boolean).join(': '); +} + + +function cleanToolError(content: string | undefined): string { + return String(content ?? '').replace(/\s+/g, ' ').trim(); +} diff --git a/ui/src/slashCommands.test.ts b/ui/src/slashCommands.test.ts new file mode 100644 index 0000000..a324e43 --- /dev/null +++ b/ui/src/slashCommands.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import {matchingSlashCommands, shouldShowSlashCommands, slashCommandQuery} from './slashCommands.js'; + +assert.equal(shouldShowSlashCommands('/', false), true); +assert.equal(shouldShowSlashCommands('/co', false), true); +assert.equal(shouldShowSlashCommands('/co', true), false); +assert.equal(shouldShowSlashCommands('hello', false), false); + +assert.equal(slashCommandQuery('/compact now'), '/compact'); +assert.equal(slashCommandQuery('/co'), '/co'); + +assert.deepEqual(matchingSlashCommands('/').map((command) => command.name), ['/compact']); +assert.deepEqual(matchingSlashCommands('/com').map((command) => command.name), ['/compact']); +assert.deepEqual(matchingSlashCommands('/compact').map((command) => command.name), ['/compact']); +assert.deepEqual(matchingSlashCommands('/x').map((command) => command.name), []); + +const slashOnly = matchingSlashCommands('/')[0]; +assert.equal(slashOnly?.matchedPrefix, '/'); +assert.equal(slashOnly?.unmatchedSuffix, 'compact'); + +const partial = matchingSlashCommands('/co')[0]; +assert.equal(partial?.matchedPrefix, '/co'); +assert.equal(partial?.unmatchedSuffix, 'mpact'); + +const full = matchingSlashCommands('/compact')[0]; +assert.equal(full?.matchedPrefix, '/compact'); +assert.equal(full?.unmatchedSuffix, ''); + +console.log('slashCommands tests passed'); diff --git a/ui/src/slashCommands.ts b/ui/src/slashCommands.ts new file mode 100644 index 0000000..74bdf5f --- /dev/null +++ b/ui/src/slashCommands.ts @@ -0,0 +1,33 @@ +export type SlashCommand = { + name: string; + description: string; +}; + +export type SlashCommandMatch = SlashCommand & { + matchedPrefix: string; + unmatchedSuffix: string; +}; + +export const SLASH_COMMANDS: SlashCommand[] = [ + {name: '/compact', description: 'Compact the current session context'}, +]; + +export function matchingSlashCommands(input: string): SlashCommandMatch[] { + if (!input.startsWith('/')) return []; + const query = slashCommandQuery(input); + return SLASH_COMMANDS + .filter((command) => command.name.toLowerCase().startsWith(query.toLowerCase())) + .map((command) => ({ + ...command, + matchedPrefix: command.name.slice(0, query.length), + unmatchedSuffix: command.name.slice(query.length), + })); +} + +export function slashCommandQuery(input: string): string { + return input.trimStart().split(/\s+/, 1)[0] ?? ''; +} + +export function shouldShowSlashCommands(input: string, busy: boolean): boolean { + return !busy && input.startsWith('/'); +} diff --git a/ui/src/types.ts b/ui/src/types.ts new file mode 100644 index 0000000..eed62b1 --- /dev/null +++ b/ui/src/types.ts @@ -0,0 +1,50 @@ +export type WorkspaceLine = + | {kind: 'user'; text: string} + | {kind: 'assistant'; text: string} + | {kind: 'thinking'; text: string} + | {kind: 'status'; text: string} + | {kind: 'workflow'; text: string} + | {kind: 'tool'; text: string; status?: 'running' | 'done' | 'error'} + | {kind: 'terminal'; text: string} + | {kind: 'session'; text: string} + | {kind: 'error'; text: string}; + +export type ToolResult = { + name?: string; + ok?: boolean; + content?: string; + metadata?: Record; +}; + +export type StreamEvent = { + type?: string; + turn_id?: string; + delta?: string; + phase?: string; + message?: string; + stream?: string; + line?: string; + assistant?: string; + tool?: {name?: string; requested_name?: string; arguments?: Record}; + progress?: {stage?: string; message?: string; metadata?: Record}; + tool_result?: ToolResult; + session?: { + active_session_id?: string; + session?: { + session_id?: string; + stage?: string; + working_dir?: string; + } | null; + }; + prompt_trace?: { + total_estimated_tokens?: number; + totals?: { + total_tokens?: number; + total_estimated_tokens?: number; + }; + }; +}; + +export type MappingState = { + assistantStreaming: boolean; +}; diff --git a/ui/src/workspaceMeta.test.ts b/ui/src/workspaceMeta.test.ts new file mode 100644 index 0000000..553cb56 --- /dev/null +++ b/ui/src/workspaceMeta.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import {compactionBar, compactionLabel, compactTargetFromEnv, resolveWorkspacePath} from './workspaceMeta.js'; + +assert.equal(compactTargetFromEnv({}), 160000); +assert.equal(compactTargetFromEnv({VIMAX_AUTO_COMPACT_TOKEN_THRESHOLD: '100', VIMAX_AUTO_COMPACT_BUFFER_TOKENS: '30'}), 70); +assert.equal(compactTargetFromEnv({VIMAX_CONTEXT_WINDOW_TOKENS: '400000', VIMAX_AUTO_COMPACT_RATIO: '0.9', VIMAX_AUTO_COMPACT_BUFFER_TOKENS: '30000'}), 330000); +assert.equal(compactionBar(0, 100, 4), '░░░░'); +assert.equal(compactionBar(50, 100, 4), '██░░'); +assert.equal(compactionBar(200, 100, 4), '████'); +assert.equal(compactionLabel(50, 100), 'Compaction [█████████░░░░░░░░░] 50/100 (50%)'); +assert.equal(resolveWorkspacePath('/repo', '.working_dir/s1'), '.working_dir/s1'); +assert.equal(resolveWorkspacePath('/repo', '/repo/.working_dir/s1'), '.working_dir/s1'); +assert.equal(resolveWorkspacePath('/repo'), '.working_dir'); +assert.equal(resolveWorkspacePath('/repo', '20260608-vimax'), '.working_dir/20260608-vimax'); + +console.log('workspaceMeta tests passed'); diff --git a/ui/src/workspaceMeta.ts b/ui/src/workspaceMeta.ts new file mode 100644 index 0000000..79014ac --- /dev/null +++ b/ui/src/workspaceMeta.ts @@ -0,0 +1,53 @@ +export type WorkspaceMeta = { + workspacePath: string; + sessionId: string; + stage: string; + compactionUsed: number; + compactionTarget: number; +}; + +export function compactTargetFromEnv(env: Record): number { + const contextWindow = parsePositiveInt(env.VIMAX_CONTEXT_WINDOW_TOKENS, 200000); + const ratio = parsePositiveFloat(env.VIMAX_AUTO_COMPACT_RATIO, 0.9); + const threshold = parsePositiveInt(env.VIMAX_AUTO_COMPACT_TOKEN_THRESHOLD, Math.round(contextWindow * Math.min(1, Math.max(0, ratio)))); + const buffer = parsePositiveInt(env.VIMAX_AUTO_COMPACT_BUFFER_TOKENS, 20000); + return Math.max(0, threshold - buffer); +} + +export function compactionBar(used: number, target: number, width = 18): string { + const safeWidth = Math.max(4, width); + if (target <= 0) return '░'.repeat(safeWidth); + const ratio = Math.min(1, Math.max(0, used / target)); + const filled = Math.round(ratio * safeWidth); + return '█'.repeat(filled) + '░'.repeat(safeWidth - filled); +} + +export function compactionLabel(used: number, target: number): string { + if (target <= 0) return 'Compaction disabled'; + const safeUsed = Math.max(0, Math.round(used)); + const percent = Math.min(999, Math.max(0, Math.round((safeUsed / target) * 100))); + return `Compaction [${compactionBar(safeUsed, target)}] ${safeUsed}/${target} (${percent}%)`; +} + +export function resolveWorkspacePath(_repoRoot: string, workingDir?: string): string { + const normalized = String(workingDir ?? '').trim(); + if (!normalized) return '.working_dir'; + const relative = normalized.replace(/^.*\/\.working_dir\//, '.working_dir/').replace(/^\.\//, ''); + if (relative.startsWith('.working_dir/')) return relative; + if (relative === '.working_dir') return relative; + return `.working_dir/${relative.replace(/^\/+/, '')}`; +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} + +function parsePositiveFloat(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..32934fe --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/image.py b/utils/image.py new file mode 100644 index 0000000..b792296 --- /dev/null +++ b/utils/image.py @@ -0,0 +1,60 @@ +import logging +import requests +import base64 +import mimetypes +from io import BytesIO +import cv2 + +from utils.retry import download_retry + + +@download_retry +def download_image(url, save_path): + try: + logging.info(f"Downloading image from {url} to {save_path}") + + response = requests.get(url, stream=True, timeout=(10, 300)) + response.raise_for_status() # Check for HTTP errors + + with open(save_path, 'wb') as file: + for chunk in response.iter_content(chunk_size=1024): + file.write(chunk) + logging.info(f"Image downloaded successfully to {save_path}") + + except Exception as e: + logging.error(f"Error downloading image: {e}") + raise e + + +def image_path_to_b64(image_path, mime: bool = True) -> str: + with open(image_path, 'rb') as image_file: + b64 = base64.b64encode(image_file.read()).decode('utf-8') + + if mime: + mime_type, _ = mimetypes.guess_type(image_path) + if mime_type is None: + mime_type = 'application/octet-stream' + return f"data:{mime_type};base64,{b64}" + + return b64 + + +def pil_to_b64(image, mime: bool = True) -> str: + buffered = BytesIO() + image.save(buffered, format="PNG") + b64 = base64.b64encode(buffered.getvalue()).decode('utf-8') + + if mime: + return f"data:image/png;base64,{b64}" + + return b64 + + +def save_base64_image(b64_string, save_path): + # If the base64 string has a data URL prefix, remove it + if ',' in b64_string: + b64_string = b64_string.split(',')[1] + + with open(save_path, 'wb') as image_file: + image_file.write(base64.b64decode(b64_string)) + diff --git a/utils/provider_presets.py b/utils/provider_presets.py new file mode 100644 index 0000000..c804820 --- /dev/null +++ b/utils/provider_presets.py @@ -0,0 +1,100 @@ +""" +Provider preset system for ViMax chat model configuration. + +Supports auto-detection and resolution of LLM provider settings, +allowing users to specify a provider name (e.g., ``minimax``) instead +of manually configuring base_url and model details. +""" + +import os +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Provider presets +# --------------------------------------------------------------------------- + +PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = { + "minimax": { + "base_url": "https://api.minimax.io/v1", + "env_key": "MINIMAX_API_KEY", + "default_model": "MiniMax-M3", + "models": [ + "MiniMax-M3", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + ], + "temperature_range": (0.0, 1.0), + }, +} + + +def resolve_chat_model_config(init_args: Dict[str, Any]) -> Dict[str, Any]: + """Resolve provider presets and return final ``init_chat_model`` kwargs. + + If ``model_provider`` matches a known preset (e.g. ``minimax``), the + returned dict will have: + + * ``model_provider`` rewritten to ``"openai"`` (OpenAI-compatible API) + * ``base_url`` filled in from the preset when not already set + * ``api_key`` sourced from the environment when not already set + * ``model`` defaulted to the preset's default model when not already set + * ``temperature`` clamped to the provider's supported range + + For unknown providers the dict is returned unchanged. + """ + args = dict(init_args) # shallow copy + provider = args.get("model_provider", "openai") + + preset = PROVIDER_PRESETS.get(provider) + if preset is None: + return args + + # base_url + if not args.get("base_url"): + args["base_url"] = preset["base_url"] + + # api_key – fall back to env var + if not args.get("api_key"): + env_key = preset.get("env_key", "") + env_val = os.environ.get(env_key, "") + if env_val: + args["api_key"] = env_val + logger.info("Using %s API key from environment variable %s", provider, env_key) + + # default model + if not args.get("model"): + args["model"] = preset["default_model"] + logger.info("Defaulting to model %s for provider %s", args["model"], provider) + + # temperature clamping + temp_range = preset.get("temperature_range") + if temp_range and "temperature" in args and args["temperature"] is not None: + lo, hi = temp_range + original = args["temperature"] + args["temperature"] = max(lo, min(hi, original)) + if args["temperature"] != original: + logger.warning( + "Clamped temperature %.2f -> %.2f for provider %s", + original, args["temperature"], provider, + ) + + # rewrite to openai-compatible provider for LangChain + args["model_provider"] = "openai" + + return args + + +def detect_provider_from_env() -> Optional[str]: + """Return the name of a provider whose API key is found in the environment. + + Checks ``PROVIDER_PRESETS`` in definition order and returns the first + match, or ``None`` if no key is set. + """ + for name, preset in PROVIDER_PRESETS.items(): + env_key = preset.get("env_key", "") + if env_key and os.environ.get(env_key): + return name + return None diff --git a/utils/rate_limiter.py b/utils/rate_limiter.py new file mode 100644 index 0000000..3cde622 --- /dev/null +++ b/utils/rate_limiter.py @@ -0,0 +1,94 @@ +import asyncio +import time +from typing import Optional + + +class RateLimiter: + """ + Rate limiter to control API request frequency. + + Ensures that no more than max_requests_per_minute requests are made per minute + and no more than max_requests_per_day requests are made per day. + """ + + def __init__( + self, + max_requests_per_minute: Optional[int] = None, + max_requests_per_day: Optional[int] = None + ): + """ + Initialize the rate limiter. + + Args: + max_requests_per_minute: Maximum number of requests allowed per minute. + If None, no per-minute limit is enforced. + max_requests_per_day: Maximum number of requests allowed per day. + If None, no per-day limit is enforced. + """ + self.max_requests_per_minute = max_requests_per_minute + self.max_requests_per_day = max_requests_per_day + self.request_times = [] + self.lock = asyncio.Lock() + + # If per-minute rate limiting is enabled, calculate the minimum delay between requests + if max_requests_per_minute and max_requests_per_minute > 0: + self.min_delay = 60.0 / max_requests_per_minute + else: + self.min_delay = 0 + + async def acquire(self): + """ + Acquire permission to make a request. + + This method will block until it's safe to make a request according to the rate limits. + + The lock is only held while checking and recording, never while sleeping: + a caller waiting out a window (up to 24h for the daily limit) must not + block every other caller's check. After each sleep the limits are + re-checked, since another caller may have taken the freed slot. + """ + if not self.max_requests_per_minute and not self.max_requests_per_day: + # Rate limiting is disabled + return + + while True: + message = None + async with self.lock: + current_time = time.time() + + # Clean up old request times (keep requests from last 24 hours for daily limit) + if self.max_requests_per_day: + self.request_times = [t for t in self.request_times if current_time - t < 86400] + elif self.max_requests_per_minute: + self.request_times = [t for t in self.request_times if current_time - t < 60] + + wait_time = 0.0 + + # Check daily limit first + if self.max_requests_per_day and self.max_requests_per_day > 0: + daily_requests = [t for t in self.request_times if current_time - t < 86400] + if len(daily_requests) >= self.max_requests_per_day: + wait_time = 86400 - (current_time - daily_requests[0]) + hours = wait_time / 3600 + message = f"Daily rate limit reached ({self.max_requests_per_day} requests/day). Waiting {hours:.1f} hours..." + + # Check per-minute limit + if wait_time <= 0 and self.max_requests_per_minute and self.max_requests_per_minute > 0: + minute_requests = [t for t in self.request_times if current_time - t < 60] + if len(minute_requests) >= self.max_requests_per_minute: + wait_time = 60 - (current_time - minute_requests[0]) + message = f"Rate limit reached ({self.max_requests_per_minute} requests/min). Waiting {wait_time:.1f}s..." + elif self.request_times and self.min_delay > 0: + # Also ensure minimum delay between consecutive requests + time_since_last = current_time - self.request_times[-1] + if time_since_last < self.min_delay: + wait_time = self.min_delay - time_since_last + + if wait_time <= 0: + # Record this request + self.request_times.append(current_time) + return + + if message: + print(message) + await asyncio.sleep(wait_time) diff --git a/utils/retry.py b/utils/retry.py new file mode 100644 index 0000000..865c4f8 --- /dev/null +++ b/utils/retry.py @@ -0,0 +1,29 @@ +import tenacity +import traceback +import logging + +import requests + +def after_func(retry_state: tenacity.RetryCallState) -> None: + if retry_state.outcome.failed: + exc = retry_state.outcome.exception() + logging.warning(f"Retrying {retry_state.fn.__name__} due to {repr(exc)} (Attempt {retry_state.attempt_number})") + logging.debug(traceback.format_exception(type(exc), exc, exc.__traceback__)) + + +def is_retryable_download_error(exc: BaseException) -> bool: + """Network errors and 5xx responses are retryable; other HTTP errors (expired + or invalid URLs, auth failures) will never succeed and must fail fast.""" + if isinstance(exc, requests.HTTPError): + response = exc.response + return response is None or response.status_code >= 500 + return isinstance(exc, requests.RequestException) + + +download_retry = tenacity.retry( + stop=tenacity.stop_after_attempt(3), + wait=tenacity.wait_exponential(multiplier=1, max=10), + retry=tenacity.retry_if_exception(is_retryable_download_error), + after=after_func, + reraise=True, +) diff --git a/utils/text.py b/utils/text.py new file mode 100644 index 0000000..09cd49e --- /dev/null +++ b/utils/text.py @@ -0,0 +1,14 @@ +import re + + +def safe_path_component(name) -> str: + """Sanitize an LLM-derived identifier for use as a filesystem path component. + + Identifiers come from model output over user-supplied story text, so they may + contain separators or traversal sequences; keep word characters (including + CJK), dashes, dots and spaces, replace everything else, and strip leading + dots so the result can never escape or hide within the working directory. + """ + cleaned = re.sub(r"[^\w\-. ]", "_", str(name)) + cleaned = cleaned.strip().lstrip(".") + return cleaned or "unnamed" diff --git a/utils/timer.py b/utils/timer.py new file mode 100644 index 0000000..3bfd7a6 --- /dev/null +++ b/utils/timer.py @@ -0,0 +1,70 @@ +import time +from functools import wraps + + +class Timer: + def __init__( + self, + prefix: str = "Start at {start_time}", + postfix: str = "End at {end_time}, took {duration} seconds.", + ): + self.prefix = prefix + self.format = format + self.postfix = postfix + + def __call__( + self, + func, + ): + @wraps(func) + async def wrapper(*args, **kwargs): + start_time = time.time() + prefix = self.prefix.replace("{start_time}", time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start_time))) + print(prefix) + + result = await func(*args, **kwargs) + + end_time = time.time() + duration = end_time - start_time + postfix = self.postfix.replace("{end_time}", time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(end_time))).replace("{duration}", f"{duration:.2f}") + print(postfix) + + return result + + return wrapper + + + def __enter__(self): + self.start_time = time.time() + prefix = self.prefix.replace("{start_time}", time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.start_time))) + print(prefix) + return self + + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is not None: + return False + + end_time = time.time() + duration = end_time - self.start_time + postfix = self.postfix.replace("{end_time}", time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(end_time))).replace("{duration}", f"{duration:.2f}") + print(postfix) + return False + + + + + +if __name__ == "__main__": + with Timer( + prefix="Begin timing at {start_time}", + postfix="Finished at {end_time}", + ): + time.sleep(1) + + + @Timer() + def test_sleep(): + time.sleep(1) + + test_sleep() \ No newline at end of file diff --git a/utils/video.py b/utils/video.py new file mode 100644 index 0000000..7ee3e7e --- /dev/null +++ b/utils/video.py @@ -0,0 +1,44 @@ +import logging +import requests +from moviepy import VideoFileClip, concatenate_videoclips +from utils.retry import download_retry + + +@download_retry +def download_video(url, save_path): + try: + logging.info(f"Downloading video from {url} to {save_path}") + + response = requests.get(url, stream=True, timeout=(10, 300)) + response.raise_for_status() # 检查请求是否成功 + + with open(save_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + logging.info(f"Video downloaded successfully to {save_path}") + + except Exception as e: + logging.error(f"Error downloading video: {e}") + raise e + + +def concatenate_video_files(video_paths, output_path, codec="libx264", preset="medium"): + """Concatenate video files, releasing every ffmpeg reader even on failure. + + Each VideoFileClip keeps an ffmpeg subprocess and file handle open until + closed; leaking them exhausts file descriptors on long multi-scene runs. + """ + clips = [] + final = None + try: + for path in video_paths: + clips.append(VideoFileClip(path)) + final = concatenate_videoclips(clips) + final.write_videofile(output_path, codec=codec, preset=preset) + finally: + if final is not None: + final.close() + for clip in clips: + clip.close() + return output_path diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e8975c5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1720 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.13' and sys_platform != 'linux' and sys_platform != 'win32'", + "(python_full_version < '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')", + "python_full_version < '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e39ad954107ebf213a2325038a3e7a506be3d98e1435e1f82086eec4cde2/aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2", size = 7822921, upload-time = "2025-07-10T13:05:33.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/0d/29026524e9336e33d9767a1e593ae2b24c2b8b09af7c2bd8193762f76b3e/aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22", size = 701055, upload-time = "2025-07-10T13:03:45.59Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b8/a5e8e583e6c8c1056f4b012b50a03c77a669c2e9bf012b7cf33d6bc4b141/aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a", size = 475670, upload-time = "2025-07-10T13:03:47.249Z" }, + { url = "https://files.pythonhosted.org/packages/29/e8/5202890c9e81a4ec2c2808dd90ffe024952e72c061729e1d49917677952f/aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff", size = 468513, upload-time = "2025-07-10T13:03:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/23/e5/d11db8c23d8923d3484a27468a40737d50f05b05eebbb6288bafcb467356/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d", size = 1715309, upload-time = "2025-07-10T13:03:51.556Z" }, + { url = "https://files.pythonhosted.org/packages/53/44/af6879ca0eff7a16b1b650b7ea4a827301737a350a464239e58aa7c387ef/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869", size = 1697961, upload-time = "2025-07-10T13:03:53.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/94/18457f043399e1ec0e59ad8674c0372f925363059c276a45a1459e17f423/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c", size = 1753055, upload-time = "2025-07-10T13:03:55.368Z" }, + { url = "https://files.pythonhosted.org/packages/26/d9/1d3744dc588fafb50ff8a6226d58f484a2242b5dd93d8038882f55474d41/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7", size = 1799211, upload-time = "2025-07-10T13:03:57.216Z" }, + { url = "https://files.pythonhosted.org/packages/73/12/2530fb2b08773f717ab2d249ca7a982ac66e32187c62d49e2c86c9bba9b4/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660", size = 1718649, upload-time = "2025-07-10T13:03:59.469Z" }, + { url = "https://files.pythonhosted.org/packages/b9/34/8d6015a729f6571341a311061b578e8b8072ea3656b3d72329fa0faa2c7c/aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088", size = 1634452, upload-time = "2025-07-10T13:04:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/08b83ea02595a582447aeb0c1986792d0de35fe7a22fb2125d65091cbaf3/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7", size = 1695511, upload-time = "2025-07-10T13:04:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/9c7c31037a063eec13ecf1976185c65d1394ded4a5120dd5965e3473cb21/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9", size = 1716967, upload-time = "2025-07-10T13:04:06.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/02/84406e0ad1acb0fb61fd617651ab6de760b2d6a31700904bc0b33bd0894d/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3", size = 1657620, upload-time = "2025-07-10T13:04:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/07/53/da018f4013a7a179017b9a274b46b9a12cbeb387570f116964f498a6f211/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb", size = 1737179, upload-time = "2025-07-10T13:04:10.182Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/ca01c5ccfeaafb026d85fa4f43ceb23eb80ea9c1385688db0ef322c751e9/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425", size = 1765156, upload-time = "2025-07-10T13:04:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/22/32/5501ab525a47ba23c20613e568174d6c63aa09e2caa22cded5c6ea8e3ada/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0", size = 1724766, upload-time = "2025-07-10T13:04:13.961Z" }, + { url = "https://files.pythonhosted.org/packages/06/af/28e24574801fcf1657945347ee10df3892311c2829b41232be6089e461e7/aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729", size = 422641, upload-time = "2025-07-10T13:04:16.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/d5/7ac2464aebd2eecac38dbe96148c9eb487679c512449ba5215d233755582/aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338", size = 449316, upload-time = "2025-07-10T13:04:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/06/48/e0d2fa8ac778008071e7b79b93ab31ef14ab88804d7ba71b5c964a7c844e/aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767", size = 695471, upload-time = "2025-07-10T13:04:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e7/f73206afa33100804f790b71092888f47df65fd9a4cd0e6800d7c6826441/aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e", size = 473128, upload-time = "2025-07-10T13:04:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/df/e2/4dd00180be551a6e7ee979c20fc7c32727f4889ee3fd5b0586e0d47f30e1/aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63", size = 465426, upload-time = "2025-07-10T13:04:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/de/dd/525ed198a0bb674a323e93e4d928443a680860802c44fa7922d39436b48b/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d", size = 1704252, upload-time = "2025-07-10T13:04:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b1/01e542aed560a968f692ab4fc4323286e8bc4daae83348cd63588e4f33e3/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab", size = 1685514, upload-time = "2025-07-10T13:04:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/b3/06/93669694dc5fdabdc01338791e70452d60ce21ea0946a878715688d5a191/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4", size = 1737586, upload-time = "2025-07-10T13:04:30.195Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3a/18991048ffc1407ca51efb49ba8bcc1645961f97f563a6c480cdf0286310/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026", size = 1786958, upload-time = "2025-07-10T13:04:32.482Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/81e237f89a32029f9b4a805af6dffc378f8459c7b9942712c809ff9e76e5/aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd", size = 1709287, upload-time = "2025-07-10T13:04:34.493Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e3/bd67a11b0fe7fc12c6030473afd9e44223d456f500f7cf526dbaa259ae46/aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88", size = 1622990, upload-time = "2025-07-10T13:04:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/83/ba/e0cc8e0f0d9ce0904e3cf2d6fa41904e379e718a013c721b781d53dcbcca/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086", size = 1676015, upload-time = "2025-07-10T13:04:38.958Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/1e6c960520bda094c48b56de29a3d978254637ace7168dd97ddc273d0d6c/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933", size = 1707678, upload-time = "2025-07-10T13:04:41.275Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/929a3eb8c35b7f9f076a462eaa9830b32c7f27d3395397665caa5e975614/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151", size = 1650274, upload-time = "2025-07-10T13:04:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/81682a6f20dd1b18ce3d747de8eba11cbef9b270f567426ff7880b096b48/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8", size = 1726408, upload-time = "2025-07-10T13:04:45.577Z" }, + { url = "https://files.pythonhosted.org/packages/8c/17/884938dffaa4048302985483f77dfce5ac18339aad9b04ad4aaa5e32b028/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3", size = 1759879, upload-time = "2025-07-10T13:04:47.663Z" }, + { url = "https://files.pythonhosted.org/packages/95/78/53b081980f50b5cf874359bde707a6eacd6c4be3f5f5c93937e48c9d0025/aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758", size = 1708770, upload-time = "2025-07-10T13:04:49.944Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/228eeddb008ecbe3ffa6c77b440597fdf640307162f0c6488e72c5a2d112/aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5", size = 421688, upload-time = "2025-07-10T13:04:51.993Z" }, + { url = "https://files.pythonhosted.org/packages/66/5f/8427618903343402fdafe2850738f735fd1d9409d2a8f9bcaae5e630d3ba/aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa", size = 448098, upload-time = "2025-07-10T13:04:53.999Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "autolongvideogeneration" +version = "1.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "chardet" }, + { name = "faiss-cpu" }, + { name = "google-genai" }, + { name = "langchain" }, + { name = "langchain-community" }, + { name = "langchain-openai" }, + { name = "moviepy" }, + { name = "openai" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scenedetect", extra = ["opencv"] }, + { name = "tenacity" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.12.14" }, + { name = "chardet", specifier = ">=5.2.0" }, + { name = "faiss-cpu", specifier = ">=1.12.0" }, + { name = "google-genai", specifier = ">=1.47.0" }, + { name = "langchain", specifier = ">=0.3.26" }, + { name = "langchain-community", specifier = ">=0.3.27" }, + { name = "langchain-openai", specifier = ">=0.3.27" }, + { name = "moviepy", specifier = ">=2.2.1" }, + { name = "openai", specifier = ">=1.95.0" }, + { name = "opencv-python" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "requests", specifier = ">=2.32.4" }, + { name = "scenedetect", extras = ["opencv"], specifier = ">=0.6.7.1" }, + { name = "tenacity", specifier = ">=9.1.2" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + +[[package]] +name = "certifi" +version = "2025.7.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "faiss-cpu" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/80/bb75a7ed6e824dea452a24d3434a72ed799324a688b10b047d441d270185/faiss_cpu-1.12.0.tar.gz", hash = "sha256:2f87cbcd603f3ed464ebceb857971fdebc318de938566c9ae2b82beda8e953c0", size = 69292, upload-time = "2025-08-13T06:07:26.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/58/602ed184d35742eb240cbfea237bd214f2ae7f01cb369c39f4dff392f7c9/faiss_cpu-1.12.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9b54990fcbcf90e37393909d4033520237194263c93ab6dbfae0616ef9af242b", size = 8034413, upload-time = "2025-08-13T06:06:05.564Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/f84c3d0e022cdeb73ff8406a6834a7698829fa242eb8590ddf8a0b09357f/faiss_cpu-1.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a5f5bca7e1a3e0a98480d1e2748fc86d12c28d506173e460e6746886ff0e08de", size = 3362034, upload-time = "2025-08-13T06:06:07.091Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a4ba4d285ea4f9b0824bf31ebded3171da08bfcf5376f4771cc5481f72cd/faiss_cpu-1.12.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:016e391f49933875b8d60d47f282f2e93d8ea9f9ffbda82467aa771b11a237db", size = 3834319, upload-time = "2025-08-13T06:06:08.86Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c9/be4e52fd96be601fefb313c26e1259ac2e6b556fb08cc392db641baba8c7/faiss_cpu-1.12.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2e4963c7188f57cfba248f09ebd8a14c76b5ffb87382603ccd4576f2da39d74", size = 31421585, upload-time = "2025-08-13T06:06:10.643Z" }, + { url = "https://files.pythonhosted.org/packages/4b/aa/12c6723ce30df721a6bace21398559c0367c5418c04139babc2d26d8d158/faiss_cpu-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:88bfe134f8c7cd2dda7df34f2619448906624962c8207efdd6eb1647e2f5338b", size = 9762449, upload-time = "2025-08-13T06:06:13.373Z" }, + { url = "https://files.pythonhosted.org/packages/67/15/ed2c9de47c3ebae980d6938f0ec12d739231438958bc5ab2d636b272d913/faiss_cpu-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9243ee4c224a0d74419040503f22bf067462a040281bf6f3f107ab205c97d438", size = 24156525, upload-time = "2025-08-13T06:06:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/6911de6b8fdcfa76144680c2195df6ce7e0cc920a8be8c5bbd2dfe5e3c37/faiss_cpu-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:6b8012353d50d9bc81bcfe35b226d0e5bfad345fdebe0da31848395ebc83816d", size = 18169636, upload-time = "2025-08-13T06:06:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/2f/69/d2b0f434b0ae35344280346b58d2b9a251609333424f3289c54506e60c51/faiss_cpu-1.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:8b4f5b18cbe335322a51d2785bb044036609c35bfac5915bff95eadc10e89ef1", size = 8012423, upload-time = "2025-08-13T06:06:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4e/6be5fbd2ceccd87b168c64edeefa469cd11f095bb63b16a61a29296b0fdb/faiss_cpu-1.12.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:c9c79b5f28dcf9b2e2557ce51b938b21b7a9d508e008dc1ffea7b8249e7bd443", size = 8034409, upload-time = "2025-08-13T06:06:22.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/658012a91a690d82f3587fd8e56ea1d9b9698c31970929a9dba17edd211e/faiss_cpu-1.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0db6485bc9f32b69aaccf9ad520782371a79904dcfe20b6da5cbfd61a712e85f", size = 3362034, upload-time = "2025-08-13T06:06:24.052Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/9b355309d448e1a737fac31d45e9b2484ffb0f04f10fba3b544efe6661e4/faiss_cpu-1.12.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6db5532831791d7bac089fc580e741e99869122946bb6a5f120016c83b95d10", size = 3834324, upload-time = "2025-08-13T06:06:25.506Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/d229f6cdb9cbe03020499d69c4b431b705aa19a55aa0fe698c98022b2fef/faiss_cpu-1.12.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d57ed7aac048b18809af70350c31acc0fb9f00e6c03b6ed1651fd58b174882d", size = 31421590, upload-time = "2025-08-13T06:06:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/80289ba008f14c95fbb6e94617ea9884e421ca745864fe6b8b90e1c3fc94/faiss_cpu-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:26c29290e7d1c5938e5886594dc0a2272b30728351ca5f855d4ae30704d5a6cc", size = 9762452, upload-time = "2025-08-13T06:06:30.237Z" }, + { url = "https://files.pythonhosted.org/packages/af/e7/6cc03ead5e19275e34992419e2b7d107d0295390ccf589636ff26adb41e2/faiss_cpu-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b43d0c295e93a8e5f1dd30325caaf34d4ecb51f1e3d461c7b0e71bff3a8944b", size = 24156530, upload-time = "2025-08-13T06:06:32.23Z" }, + { url = "https://files.pythonhosted.org/packages/34/90/438865fe737d65e7348680dadf3b2983bdcef7e5b7e852000e74c50a9933/faiss_cpu-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:a7c6156f1309bb969480280906e8865c3c4378eebb0f840c55c924bf06efd8d3", size = 18169604, upload-time = "2025-08-13T06:06:34.884Z" }, + { url = "https://files.pythonhosted.org/packages/76/69/40a1d8d781a70d33c57ef1b4b777486761dd1c502a86d27e90ef6aa8a9f9/faiss_cpu-1.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b5fac98a350774a98b904f7a7c6689eb5cf0a593d63c552e705a80c55636d15", size = 8012523, upload-time = "2025-08-13T06:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/12/35/01a4a7c179d67bee0d8a027b95c3eae19cb354ae69ef2bc50ac3b93bc853/faiss_cpu-1.12.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:ff7db774968210d08cd0331287f3f66a6ffef955a7aa9a7fcd3eb4432a4ce5f5", size = 8036142, upload-time = "2025-08-13T06:06:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/08/23/bac2859490096608c9d527f3041b44c2e43f8df0d4aadd53a4cc5ce678ac/faiss_cpu-1.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:220b5bb5439c64e417b35f9ade4c7dc3bf7df683d6123901ba84d6d764ecd486", size = 3363747, upload-time = "2025-08-13T06:06:40.73Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/e18023e1f43a18ec593adcd69d356f1fa94bde20344e38334d5985e5c5cc/faiss_cpu-1.12.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693d0bf16f79e8d16a1baaeda459f3375f37da0354e97dc032806b48a2a54151", size = 3835232, upload-time = "2025-08-13T06:06:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/1c1fea423d3f550f44c5ec3f14d8400919b49c285c3bd146687c63e40186/faiss_cpu-1.12.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcc6587dee21e17430fb49ddc5200625d6f5e1de2bdf436f14827bad4ca78d19", size = 31432677, upload-time = "2025-08-13T06:06:44.348Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/3483e92a02f30e2d8491a256f470f54b7f5483266dfe09126d28741d31ec/faiss_cpu-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b80e5965f001822cc99ec65c715169af1b70bdae72eccd573520a2dec485b3ee", size = 9765504, upload-time = "2025-08-13T06:06:46.567Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2f/d97792211a9bd84b8d6b1dcaa1dcd69ac11e026c6ef19c641b6a87e31025/faiss_cpu-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98279f1b4876ef9902695a329b81a99002782ab6e26def472022009df6f1ac68", size = 24169930, upload-time = "2025-08-13T06:06:48.916Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/b707ca4d88af472509a053c39d3cced53efd19d096b8dff2fadc18c4b82d/faiss_cpu-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:11670337f9f5ee9ff3490e30683eea80add060c300cf6f6cb0e8faf3155fd20e", size = 18475400, upload-time = "2025-08-13T06:06:51.233Z" }, + { url = "https://files.pythonhosted.org/packages/77/11/42e41ddebde4dfe77e36e92d0110b4f733c8640883abffde54f802482deb/faiss_cpu-1.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:7ac1c8b53609b5c722ab60f1749260a7cb3c72fdfb720a0e3033067e73591da5", size = 8281229, upload-time = "2025-08-13T06:06:53.735Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/8ae5bbeabe70eb673c37fc7c77e2e476746331afb6654b2df97d8b6d380d/faiss_cpu-1.12.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:110b21b7bb4c93c4f1a5eb2ffb8ef99dcdb4725f8ab2e5cd161324e4d981f204", size = 8087247, upload-time = "2025-08-13T06:06:55.407Z" }, + { url = "https://files.pythonhosted.org/packages/f4/df/b3d79098860b67b126da351788c04ac243c29718dadc4a678a6f5e7209c0/faiss_cpu-1.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:82eb5515ce72be9a43f4cf74447a0d090e014231981df91aff7251204b506fbf", size = 3411043, upload-time = "2025-08-13T06:06:56.983Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2f/b1a2a03dd3cce22ff9fc434aa3c7390125087260c1d1349311da36eaa432/faiss_cpu-1.12.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:754eef89cdf2b35643df6b0923a5a098bdfecf63b5f4bd86c385042ee511b287", size = 3801789, upload-time = "2025-08-13T06:06:58.688Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a8/16ad0c6a966e93d04bfd5248d2be1d8b5849842b0e2611c5ecd26fcaf036/faiss_cpu-1.12.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7285c71c8f5e9c58b55175f5f74c78c518c52c421a88a430263f34e3e31f719c", size = 31231388, upload-time = "2025-08-13T06:07:00.55Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/9c16eca0b8f8b13c32c47a5e4ff7a4bc0ca3e7d263140312088811230871/faiss_cpu-1.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84a50d7a2f711f79cc8b65aa28956dba6435e47b71a38b2daea44c94c9b8e458", size = 9737605, upload-time = "2025-08-13T06:07:03.018Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4a/2c2d615078c9d816a836fb893aaef551ad152f2eb00bc258698273c240c0/faiss_cpu-1.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f3e0a14e4edec6a3959a9f51afccb89e863138f184ff2cc24c13f9ad788740b", size = 23922880, upload-time = "2025-08-13T06:07:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/30/aa/99b8402a4dac678794f13f8f4f29d666c2ef0a91594418147f47034ebc81/faiss_cpu-1.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8b3239cc371df6826ac43c62ac04eec7cc497bedb43f681fcd8ea494f520ddbb", size = 18750661, upload-time = "2025-08-13T06:07:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a2/b546e9a20ba157eb2fbe141289f1752f157ee6d932899f4853df4ded6d4b/faiss_cpu-1.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58b23456db725ee1bd605a6135d2ef55b2ac3e0b6fe873fd99a909e8ef4bd0ff", size = 8302032, upload-time = "2025-08-13T06:07:09.602Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, +] + +[[package]] +name = "google-genai" +version = "1.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "google-auth" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/97/784fba9bc6c41263ff90cb9063eadfdd755dde79cfa5a8d0e397b067dcf9/google_genai-1.47.0.tar.gz", hash = "sha256:ecece00d0a04e6739ea76cc8dad82ec9593d9380aaabef078990e60574e5bf59", size = 241471, upload-time = "2025-10-29T22:01:02.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ef/e080e8d67c270ea320956bb911a9359664fc46d3b87d1f029decd33e5c4c/google_genai-1.47.0-py3-none-any.whl", hash = "sha256:e3851237556cbdec96007d8028b4b1f2425cdc5c099a8dc36b72a57e42821b60", size = 241506, upload-time = "2025-10-29T22:01:00.982Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload-time = "2025-06-05T16:11:23.467Z" }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload-time = "2025-06-05T16:38:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload-time = "2025-06-05T16:41:36.343Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload-time = "2025-06-05T16:13:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload-time = "2025-06-05T16:12:50.792Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload-time = "2025-06-05T16:36:48.59Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055, upload-time = "2025-06-05T16:12:40.457Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817, upload-time = "2025-06-05T16:29:49.244Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload-time = "2025-06-05T16:12:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload-time = "2025-06-05T16:20:12.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/47/57e897fb7094afb2d26e8b2e4af9a45c7cf1a405acdeeca001fdf2c98501/imageio-2.37.0.tar.gz", hash = "sha256:71b57b3669666272c818497aebba2b4c5f20d5b37c81720e5e1a56d59c492996", size = 389963, upload-time = "2025-01-20T02:42:37.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl", hash = "sha256:11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed", size = 315796, upload-time = "2025-01-20T02:42:34.931Z" }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b5/348b3313c58f5fbfb2194eb4d07e46a35748ba6e5b3b3046143f3040bafa/jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b", size = 312262, upload-time = "2025-05-18T19:03:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4a/6a2397096162b21645162825f058d1709a02965606e537e3304b02742e9b/jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744", size = 320124, upload-time = "2025-05-18T19:03:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/1ce02cade7516b726dd88f59a4ee46914bf79d1676d1228ef2002ed2f1c9/jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2", size = 345330, upload-time = "2025-05-18T19:03:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/75/d0/bb6b4f209a77190ce10ea8d7e50bf3725fc16d3372d0a9f11985a2b23eff/jiter-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:371eab43c0a288537d30e1f0b193bc4eca90439fc08a022dd83e5e07500ed026", size = 369670, upload-time = "2025-05-18T19:03:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/a61787da9b8847a601e6827fbc42ecb12be2c925ced3252c8ffcb56afcaf/jiter-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c675736059020365cebc845a820214765162728b51ab1e03a1b7b3abb70f74c", size = 489057, upload-time = "2025-05-18T19:03:50.66Z" }, + { url = "https://files.pythonhosted.org/packages/12/e4/6f906272810a7b21406c760a53aadbe52e99ee070fc5c0cb191e316de30b/jiter-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c5867d40ab716e4684858e4887489685968a47e3ba222e44cde6e4a2154f959", size = 389372, upload-time = "2025-05-18T19:03:51.98Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ba/77013b0b8ba904bf3762f11e0129b8928bff7f978a81838dfcc958ad5728/jiter-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395bb9a26111b60141757d874d27fdea01b17e8fac958b91c20128ba8f4acc8a", size = 352038, upload-time = "2025-05-18T19:03:53.703Z" }, + { url = "https://files.pythonhosted.org/packages/67/27/c62568e3ccb03368dbcc44a1ef3a423cb86778a4389e995125d3d1aaa0a4/jiter-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6842184aed5cdb07e0c7e20e5bdcfafe33515ee1741a6835353bb45fe5d1bd95", size = 391538, upload-time = "2025-05-18T19:03:55.046Z" }, + { url = "https://files.pythonhosted.org/packages/c0/72/0d6b7e31fc17a8fdce76164884edef0698ba556b8eb0af9546ae1a06b91d/jiter-0.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62755d1bcea9876770d4df713d82606c8c1a3dca88ff39046b85a048566d56ea", size = 523557, upload-time = "2025-05-18T19:03:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/2f/09/bc1661fbbcbeb6244bd2904ff3a06f340aa77a2b94e5a7373fd165960ea3/jiter-0.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533efbce2cacec78d5ba73a41756beff8431dfa1694b6346ce7af3a12c42202b", size = 514202, upload-time = "2025-05-18T19:03:57.675Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/5a5d5400e9d4d54b8004c9673bbe4403928a00d28529ff35b19e9d176b19/jiter-0.10.0-cp312-cp312-win32.whl", hash = "sha256:8be921f0cadd245e981b964dfbcd6fd4bc4e254cdc069490416dd7a2632ecc01", size = 211781, upload-time = "2025-05-18T19:03:59.025Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/7ec47455e26f2d6e5f2ea4951a0652c06e5b995c291f723973ae9e724a65/jiter-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7c7d785ae9dda68c2678532a5a1581347e9c15362ae9f6e68f3fdbfb64f2e49", size = 206176, upload-time = "2025-05-18T19:04:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617, upload-time = "2025-05-18T19:04:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947, upload-time = "2025-05-18T19:04:03.347Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618, upload-time = "2025-05-18T19:04:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829, upload-time = "2025-05-18T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034, upload-time = "2025-05-18T19:04:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529, upload-time = "2025-05-18T19:04:09.566Z" }, + { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671, upload-time = "2025-05-18T19:04:10.98Z" }, + { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864, upload-time = "2025-05-18T19:04:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989, upload-time = "2025-05-18T19:04:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495, upload-time = "2025-05-18T19:04:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289, upload-time = "2025-05-18T19:04:17.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074, upload-time = "2025-05-18T19:04:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225, upload-time = "2025-05-18T19:04:20.583Z" }, + { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235, upload-time = "2025-05-18T19:04:22.363Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278, upload-time = "2025-05-18T19:04:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866, upload-time = "2025-05-18T19:04:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772, upload-time = "2025-05-18T19:04:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534, upload-time = "2025-05-18T19:04:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087, upload-time = "2025-05-18T19:04:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694, upload-time = "2025-05-18T19:04:30.183Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992, upload-time = "2025-05-18T19:04:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723, upload-time = "2025-05-18T19:04:33.467Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215, upload-time = "2025-05-18T19:04:34.827Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762, upload-time = "2025-05-18T19:04:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427, upload-time = "2025-05-18T19:04:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127, upload-time = "2025-05-18T19:04:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527, upload-time = "2025-05-18T19:04:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "langchain" +version = "0.3.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "langsmith" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/13/a9931800ee42bbe0f8850dd540de14e80dda4945e7ee36e20b5d5964286e/langchain-0.3.26.tar.gz", hash = "sha256:8ff034ee0556d3e45eff1f1e96d0d745ced57858414dba7171c8ebdbeb5580c9", size = 10226808, upload-time = "2025-06-20T22:23:01.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/f2/c09a2e383283e3af1db669ab037ac05a45814f4b9c472c48dc24c0cef039/langchain-0.3.26-py3-none-any.whl", hash = "sha256:361bb2e61371024a8c473da9f9c55f4ee50f269c5ab43afdb2b1309cb7ac36cf", size = 1012336, upload-time = "2025-06-20T22:22:58.874Z" }, +] + +[[package]] +name = "langchain-community" +version = "0.3.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "dataclasses-json" }, + { name = "httpx-sse" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langsmith" }, + { name = "numpy" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/76/200494f6de488217a196c4369e665d26b94c8c3642d46e2fd62f9daf0a3a/langchain_community-0.3.27.tar.gz", hash = "sha256:e1037c3b9da0c6d10bf06e838b034eb741e016515c79ef8f3f16e53ead33d882", size = 33237737, upload-time = "2025-07-02T18:47:02.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/bc/f8c7dae8321d37ed39ac9d7896617c4203248240a4835b136e3724b3bb62/langchain_community-0.3.27-py3-none-any.whl", hash = "sha256:581f97b795f9633da738ea95da9cb78f8879b538090c9b7a68c0aed49c828f0d", size = 2530442, upload-time = "2025-07-02T18:47:00.246Z" }, +] + +[[package]] +name = "langchain-core" +version = "0.3.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/8d/d54586b8f65c6fc209db93916ff9e919e1cc14bad8fe66880ea4d7ea9d6c/langchain_core-0.3.86.tar.gz", hash = "sha256:671cbc96a325fe47f7dbab421236ada2d437bc4bfad0038102264885d0b462e2", size = 603154, upload-time = "2026-05-07T16:48:08.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/93/ba19ca54701c6118e68f8785949b6c0eab1df3a5cfa5310508cc86877994/langchain_core-0.3.86-py3-none-any.whl", hash = "sha256:7d2a1c50d2d2a139dbc6465cd339f32d14aa43db5ac9bd232e5b567a238709e8", size = 461306, upload-time = "2026-05-07T16:48:06.283Z" }, +] + +[[package]] +name = "langchain-openai" +version = "0.3.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/e65261a08a03dd43f0ef8a539930b56548ac8136e71258c220d3589d1d07/langchain_openai-0.3.27.tar.gz", hash = "sha256:5d5a55adbff739274dfc3a4102925771736f893758f63679b64ae62fed79ca30", size = 753326, upload-time = "2025-06-27T17:56:29.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/31/1f0baf6490b082bf4d06f355c5e9c28728931dbf321f3ca03137617a692e/langchain_openai-0.3.27-py3-none-any.whl", hash = "sha256:efe636c3523978c44adc41cf55c8b3766c05c77547982465884d1258afe705df", size = 70368, upload-time = "2025-06-27T17:56:28.726Z" }, +] + +[[package]] +name = "langchain-text-splitters" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/ac/b4a25c5716bb0103b1515f1f52cc69ffb1035a5a225ee5afe3aed28bf57b/langchain_text_splitters-0.3.8.tar.gz", hash = "sha256:116d4b9f2a22dda357d0b79e30acf005c5518177971c66a9f1ab0edfdb0f912e", size = 42128, upload-time = "2025-04-04T14:03:51.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/a3/3696ff2444658053c01b6b7443e761f28bb71217d82bb89137a978c5f66f/langchain_text_splitters-0.3.8-py3-none-any.whl", hash = "sha256:e75cc0f4ae58dcf07d9f18776400cf8ade27fadd4ff6d264df6278bb302f6f02", size = 32440, upload-time = "2025-04-04T14:03:50.6Z" }, +] + +[[package]] +name = "langsmith" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/92/7885823f3d13222f57773921f0da19b37d628c64607491233dc853a0f6ea/langsmith-0.4.5.tar.gz", hash = "sha256:49444bd8ccd4e46402f1b9ff1d686fa8e3a31b175e7085e72175ab8ec6164a34", size = 352235, upload-time = "2025-07-10T22:08:04.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/10/ad3107b666c3203b7938d10ea6b8746b9735c399cf737a51386d58e41d34/langsmith-0.4.5-py3-none-any.whl", hash = "sha256:4167717a2cccc4dff5809dbddc439628e836f6fd13d4fdb31ea013bc8d5cfaf5", size = 367795, upload-time = "2025-07-10T22:08:02.548Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload-time = "2025-02-03T15:32:25.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, +] + +[[package]] +name = "moviepy" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "imageio" }, + { name = "imageio-ffmpeg" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "proglog" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/61/15f9476e270f64c78a834e7459ca045d669f869cec24eed26807b8cd479d/moviepy-2.2.1.tar.gz", hash = "sha256:c80cb56815ece94e5e3e2d361aa40070eeb30a09d23a24c4e684d03e16deacb1", size = 58431438, upload-time = "2025-05-21T19:31:52.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/73/7d3b2010baa0b5eb1e4dfa9e4385e89b6716be76f2fa21a6c0fe34b68e5a/moviepy-2.2.1-py3-none-any.whl", hash = "sha256:6b56803fec2ac54b557404126ac1160e65448e03798fa282bd23e8fab3795060", size = 129871, upload-time = "2025-05-21T19:31:50.11Z" }, +] + +[[package]] +name = "multidict" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/a0/6b57988ea102da0623ea814160ed78d45a2645e4bbb499c2896d12833a70/multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6", size = 76514, upload-time = "2025-06-30T15:51:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/d1e92665b0850c6c0508f101f9cf0410c1afa24973e1115fe9c6a185ebf7/multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f", size = 45394, upload-time = "2025-06-30T15:51:49.986Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/dd104490e01be6ef8bf9573705d8572f8c2d2c561f06e3826b081d9e6591/multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55", size = 43590, upload-time = "2025-06-30T15:51:51.331Z" }, + { url = "https://files.pythonhosted.org/packages/44/fe/06e0e01b1b0611e6581b7fd5a85b43dacc08b6cea3034f902f383b0873e5/multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b", size = 237292, upload-time = "2025-06-30T15:51:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/4f0e558fb77696b89c233c1ee2d92f3e1d5459070a0e89153c9e9e804186/multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888", size = 258385, upload-time = "2025-06-30T15:51:53.913Z" }, + { url = "https://files.pythonhosted.org/packages/e3/25/cca0e68228addad24903801ed1ab42e21307a1b4b6dd2cf63da5d3ae082a/multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d", size = 242328, upload-time = "2025-06-30T15:51:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a3/46f2d420d86bbcb8fe660b26a10a219871a0fbf4d43cb846a4031533f3e0/multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680", size = 268057, upload-time = "2025-06-30T15:51:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/9e/73/1c743542fe00794a2ec7466abd3f312ccb8fad8dff9f36d42e18fb1ec33e/multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a", size = 269341, upload-time = "2025-06-30T15:51:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/a4/11/6ec9dcbe2264b92778eeb85407d1df18812248bf3506a5a1754bc035db0c/multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961", size = 256081, upload-time = "2025-06-30T15:52:00.533Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2b/631b1e2afeb5f1696846d747d36cda075bfdc0bc7245d6ba5c319278d6c4/multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65", size = 253581, upload-time = "2025-06-30T15:52:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0e/7e3b93f79efeb6111d3bf9a1a69e555ba1d07ad1c11bceb56b7310d0d7ee/multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643", size = 250750, upload-time = "2025-06-30T15:52:04.26Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/086846c1d6601948e7de556ee464a2d4c85e33883e749f46b9547d7b0704/multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063", size = 251548, upload-time = "2025-06-30T15:52:06.002Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7b/86ec260118e522f1a31550e87b23542294880c97cfbf6fb18cc67b044c66/multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3", size = 262718, upload-time = "2025-06-30T15:52:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bd/22ce8f47abb0be04692c9fc4638508b8340987b18691aa7775d927b73f72/multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75", size = 259603, upload-time = "2025-06-30T15:52:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/91b7ac1691be95cd1f4a26e36a74b97cda6aa9820632d31aab4410f46ebd/multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10", size = 251351, upload-time = "2025-06-30T15:52:10.947Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5c/4d7adc739884f7a9fbe00d1eac8c034023ef8bad71f2ebe12823ca2e3649/multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5", size = 41860, upload-time = "2025-06-30T15:52:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a3/0fbc7afdf7cb1aa12a086b02959307848eb6bcc8f66fcb66c0cb57e2a2c1/multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17", size = 45982, upload-time = "2025-06-30T15:52:13.6Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/8c825bd70ff9b02462dc18d1295dd08d3e9e4eb66856d292ffa62cfe1920/multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b", size = 43210, upload-time = "2025-06-30T15:52:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/52/1d/0bebcbbb4f000751fbd09957257903d6e002943fc668d841a4cf2fb7f872/multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55", size = 75843, upload-time = "2025-06-30T15:52:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/cbe241b0434cfe257f65c2b1bcf9e8d5fb52bc708c5061fb29b0fed22bdf/multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b", size = 45053, upload-time = "2025-06-30T15:52:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/32/d2/0b3b23f9dbad5b270b22a3ac3ea73ed0a50ef2d9a390447061178ed6bdb8/multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65", size = 43273, upload-time = "2025-06-30T15:52:19.346Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/6eb68927e823999e3683bc49678eb20374ba9615097d085298fd5b386564/multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3", size = 237124, upload-time = "2025-06-30T15:52:20.773Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/320d8507e7726c460cb77117848b3834ea0d59e769f36fdae495f7669929/multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c", size = 256892, upload-time = "2025-06-30T15:52:22.242Z" }, + { url = "https://files.pythonhosted.org/packages/76/60/38ee422db515ac69834e60142a1a69111ac96026e76e8e9aa347fd2e4591/multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6", size = 240547, upload-time = "2025-06-30T15:52:23.736Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/905224fde2dff042b030c27ad95a7ae744325cf54b890b443d30a789b80e/multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8", size = 266223, upload-time = "2025-06-30T15:52:25.185Z" }, + { url = "https://files.pythonhosted.org/packages/76/35/dc38ab361051beae08d1a53965e3e1a418752fc5be4d3fb983c5582d8784/multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca", size = 267262, upload-time = "2025-06-30T15:52:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a3/0a485b7f36e422421b17e2bbb5a81c1af10eac1d4476f2ff92927c730479/multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884", size = 254345, upload-time = "2025-06-30T15:52:28.467Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/bcdd52c1dab7c0e0d75ff19cac751fbd5f850d1fc39172ce809a74aa9ea4/multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7", size = 252248, upload-time = "2025-06-30T15:52:29.938Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/2d96aaa6eae8067ce108d4acee6f45ced5728beda55c0f02ae1072c730d1/multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b", size = 250115, upload-time = "2025-06-30T15:52:31.416Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/ed9f847fa5c7d0677d4f02ea2c163d5e48573de3f57bacf5670e43a5ffaa/multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c", size = 249649, upload-time = "2025-06-30T15:52:32.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/9155850372563fc550803d3f25373308aa70f59b52cff25854086ecb4a79/multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b", size = 261203, upload-time = "2025-06-30T15:52:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/c6a728f699896252cf309769089568a33c6439626648843f78743660709d/multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1", size = 258051, upload-time = "2025-06-30T15:52:35.999Z" }, + { url = "https://files.pythonhosted.org/packages/d0/60/689880776d6b18fa2b70f6cc74ff87dd6c6b9b47bd9cf74c16fecfaa6ad9/multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6", size = 249601, upload-time = "2025-06-30T15:52:37.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/5e/325b11f2222a549019cf2ef879c1f81f94a0d40ace3ef55cf529915ba6cc/multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e", size = 41683, upload-time = "2025-06-30T15:52:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/cf46e73f5d6e3c775cabd2a05976547f3f18b39bee06260369a42501f053/multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9", size = 45811, upload-time = "2025-06-30T15:52:40.207Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c9/2e3fe950db28fb7c62e1a5f46e1e38759b072e2089209bc033c2798bb5ec/multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600", size = 43056, upload-time = "2025-06-30T15:52:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/aaf8114cf34966e084a8cc9517771288adb53465188843d5a19862cb6dc3/multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134", size = 82811, upload-time = "2025-06-30T15:52:43.281Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/5402e7b58a1f5b987a07ad98f2501fdba2a4f4b4c30cf114e3ce8db64c87/multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37", size = 48304, upload-time = "2025-06-30T15:52:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/65/ab3c8cafe21adb45b24a50266fd747147dec7847425bc2a0f6934b3ae9ce/multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8", size = 46775, upload-time = "2025-06-30T15:52:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/9fcc1b332f67cc0c0c8079e263bfab6660f87fe4e28a35921771ff3eea0d/multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1", size = 229773, upload-time = "2025-06-30T15:52:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/a4/14/0145a251f555f7c754ce2dcbcd012939bbd1f34f066fa5d28a50e722a054/multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373", size = 250083, upload-time = "2025-06-30T15:52:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/d5c0bd2bbb173b586c249a151a26d2fb3ec7d53c96e42091c9fef4e1f10c/multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e", size = 228980, upload-time = "2025-06-30T15:52:50.903Z" }, + { url = "https://files.pythonhosted.org/packages/21/32/c9a2d8444a50ec48c4733ccc67254100c10e1c8ae8e40c7a2d2183b59b97/multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f", size = 257776, upload-time = "2025-06-30T15:52:52.764Z" }, + { url = "https://files.pythonhosted.org/packages/68/d0/14fa1699f4ef629eae08ad6201c6b476098f5efb051b296f4c26be7a9fdf/multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0", size = 256882, upload-time = "2025-06-30T15:52:54.596Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/84a27570fbe303c65607d517a5f147cd2fc046c2d1da02b84b17b9bdc2aa/multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc", size = 247816, upload-time = "2025-06-30T15:52:56.175Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/dca352a0c999ce96a5d8b8ee0b2b9f729dcad2e0b0c195f8286269a2074c/multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f", size = 245341, upload-time = "2025-06-30T15:52:57.752Z" }, + { url = "https://files.pythonhosted.org/packages/50/ef/433fa3ed06028f03946f3993223dada70fb700f763f70c00079533c34578/multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471", size = 235854, upload-time = "2025-06-30T15:52:59.74Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/487612ab56fbe35715320905215a57fede20de7db40a261759690dc80471/multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2", size = 243432, upload-time = "2025-06-30T15:53:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/ce8b79de16cd885c6f9052c96a3671373d00c59b3ee635ea93e6e81b8ccf/multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648", size = 252731, upload-time = "2025-06-30T15:53:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/a2514a6aba78e5abefa1624ca85ae18f542d95ac5cde2e3815a9fbf369aa/multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d", size = 247086, upload-time = "2025-06-30T15:53:05.48Z" }, + { url = "https://files.pythonhosted.org/packages/8c/22/b788718d63bb3cce752d107a57c85fcd1a212c6c778628567c9713f9345a/multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c", size = 243338, upload-time = "2025-06-30T15:53:07.522Z" }, + { url = "https://files.pythonhosted.org/packages/22/d6/fdb3d0670819f2228f3f7d9af613d5e652c15d170c83e5f1c94fbc55a25b/multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e", size = 47812, upload-time = "2025-06-30T15:53:09.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d6/a9d2c808f2c489ad199723197419207ecbfbc1776f6e155e1ecea9c883aa/multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d", size = 53011, upload-time = "2025-06-30T15:53:11.038Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/b68001cba8188dd267590a111f9661b6256debc327137667e832bf5d66e8/multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb", size = 45254, upload-time = "2025-06-30T15:53:12.421Z" }, + { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, +] + +[[package]] +name = "openai" +version = "1.102.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/55/da5598ed5c6bdd9939633854049cddc5cbac0da938dfcfcb3c6b119c16c0/openai-1.102.0.tar.gz", hash = "sha256:2e0153bcd64a6523071e90211cbfca1f2bbc5ceedd0993ba932a5869f93b7fc9", size = 519027, upload-time = "2025-08-26T20:50:29.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0d/c9e7016d82c53c5b5e23e2bad36daebb8921ed44f69c0a985c6529a35106/openai-1.102.0-py3-none-any.whl", hash = "sha256:d751a7e95e222b5325306362ad02a7aa96e1fab3ed05b5888ce1c7ca63451345", size = 812015, upload-time = "2025-08-26T20:50:27.219Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.12.0.88" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/71/25c98e634b6bdeca4727c7f6d6927b056080668c5008ad3c8fc9e7f8f6ec/opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d", size = 95373294, upload-time = "2025-07-07T09:20:52.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/68/3da40142e7c21e9b1d4e7ddd6c58738feb013203e6e4b803d62cdd9eb96b/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5", size = 37877727, upload-time = "2025-07-07T09:13:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/33/7c/042abe49f58d6ee7e1028eefc3334d98ca69b030e3b567fe245a2b28ea6f/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81", size = 57326471, upload-time = "2025-07-07T09:13:41.26Z" }, + { url = "https://files.pythonhosted.org/packages/62/3a/440bd64736cf8116f01f3b7f9f2e111afb2e02beb2ccc08a6458114a6b5d/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92", size = 45887139, upload-time = "2025-07-07T09:13:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/68/1f/795e7f4aa2eacc59afa4fb61a2e35e510d06414dd5a802b51a012d691b37/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9", size = 67041680, upload-time = "2025-07-07T09:14:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" }, +] + +[[package]] +name = "orjson" +version = "3.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810, upload-time = "2025-04-29T23:30:08.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184, upload-time = "2025-04-29T23:28:53.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279, upload-time = "2025-04-29T23:28:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799, upload-time = "2025-04-29T23:28:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791, upload-time = "2025-04-29T23:28:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059, upload-time = "2025-04-29T23:29:00.129Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359, upload-time = "2025-04-29T23:29:01.704Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853, upload-time = "2025-04-29T23:29:03.576Z" }, + { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131, upload-time = "2025-04-29T23:29:05.753Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834, upload-time = "2025-04-29T23:29:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368, upload-time = "2025-04-29T23:29:09.301Z" }, + { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359, upload-time = "2025-04-29T23:29:10.813Z" }, + { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466, upload-time = "2025-04-29T23:29:12.26Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683, upload-time = "2025-04-29T23:29:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754, upload-time = "2025-04-29T23:29:15.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218, upload-time = "2025-04-29T23:29:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087, upload-time = "2025-04-29T23:29:19.083Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273, upload-time = "2025-04-29T23:29:20.602Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779, upload-time = "2025-04-29T23:29:22.062Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811, upload-time = "2025-04-29T23:29:23.602Z" }, + { url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018, upload-time = "2025-04-29T23:29:25.094Z" }, + { url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368, upload-time = "2025-04-29T23:29:26.609Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840, upload-time = "2025-04-29T23:29:28.153Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135, upload-time = "2025-04-29T23:29:29.726Z" }, + { url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810, upload-time = "2025-04-29T23:29:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491, upload-time = "2025-04-29T23:29:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277, upload-time = "2025-04-29T23:29:34.946Z" }, + { url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367, upload-time = "2025-04-29T23:29:36.52Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687, upload-time = "2025-04-29T23:29:38.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794, upload-time = "2025-04-29T23:29:40.349Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186, upload-time = "2025-04-29T23:29:41.922Z" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "proglog" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/af/c108866c452eda1132f3d6b3cb6be2ae8430c97e9309f38ca9dbd430af37/proglog-0.1.12.tar.gz", hash = "sha256:361ee074721c277b89b75c061336cb8c5f287c92b043efa562ccf7866cda931c", size = 8794, upload-time = "2025-05-09T14:36:18.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/1b/f7ea6cde25621cd9236541c66ff018f4268012a534ec31032bcb187dc5e7/proglog-0.1.12-py3-none-any.whl", hash = "sha256:ccaafce51e80a81c65dc907a460c07ccb8ec1f78dc660cfd8f9ec3a22f01b84c", size = 6337, upload-time = "2025-05-09T14:36:16.798Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494, upload-time = "2024-11-06T20:12:31.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781, upload-time = "2024-11-06T20:10:07.07Z" }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455, upload-time = "2024-11-06T20:10:09.117Z" }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759, upload-time = "2024-11-06T20:10:11.155Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976, upload-time = "2024-11-06T20:10:13.24Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077, upload-time = "2024-11-06T20:10:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160, upload-time = "2024-11-06T20:10:19.027Z" }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896, upload-time = "2024-11-06T20:10:21.85Z" }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997, upload-time = "2024-11-06T20:10:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725, upload-time = "2024-11-06T20:10:28.067Z" }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481, upload-time = "2024-11-06T20:10:31.612Z" }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896, upload-time = "2024-11-06T20:10:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138, upload-time = "2024-11-06T20:10:36.142Z" }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692, upload-time = "2024-11-06T20:10:38.394Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135, upload-time = "2024-11-06T20:10:40.367Z" }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567, upload-time = "2024-11-06T20:10:43.467Z" }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525, upload-time = "2024-11-06T20:10:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324, upload-time = "2024-11-06T20:10:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617, upload-time = "2024-11-06T20:10:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023, upload-time = "2024-11-06T20:10:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072, upload-time = "2024-11-06T20:10:52.926Z" }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130, upload-time = "2024-11-06T20:10:54.828Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857, upload-time = "2024-11-06T20:10:56.634Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006, upload-time = "2024-11-06T20:10:59.369Z" }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650, upload-time = "2024-11-06T20:11:02.042Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545, upload-time = "2024-11-06T20:11:03.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045, upload-time = "2024-11-06T20:11:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182, upload-time = "2024-11-06T20:11:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733, upload-time = "2024-11-06T20:11:11.256Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122, upload-time = "2024-11-06T20:11:13.161Z" }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545, upload-time = "2024-11-06T20:11:15Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "scenedetect" +version = "0.6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "numpy" }, + { name = "platformdirs" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/e77e1812ae89bc4864ff54efb6a8232eabebd471e372096cb711f03cca52/scenedetect-0.6.7.1.tar.gz", hash = "sha256:07833b0cb83a0106786a88136462580e9865e097f411f01501a688714c483a4e", size = 164208, upload-time = "2025-09-25T03:18:59.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/5b/2294ea44b3b2264a50b82a55a3822837a04cb779c873fc168844f36f5b46/scenedetect-0.6.7.1-py3-none-any.whl", hash = "sha256:3808ef4436ab0fc6fee8a155e95759b42e2446c925c45746a82ff119be4eb3e1", size = 130860, upload-time = "2025-09-25T03:18:58.43Z" }, +] + +[package.optional-dependencies] +opencv = [ + { name = "opencv-python" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/cf/756fedf6981e82897f2d570dd25fa597eb3f4459068ae0572d7e888cfd6f/tiktoken-0.9.0.tar.gz", hash = "sha256:d02a5ca6a938e0490e1ff957bc48c8b078c88cb83977be1625b1fd8aac792c5d", size = 35991, upload-time = "2025-02-14T06:03:01.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/e5/21ff33ecfa2101c1bb0f9b6df750553bd873b7fb532ce2cb276ff40b197f/tiktoken-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e88f121c1c22b726649ce67c089b90ddda8b9662545a8aeb03cfef15967ddd03", size = 1065073, upload-time = "2025-02-14T06:02:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/8e/03/a95e7b4863ee9ceec1c55983e4cc9558bcfd8f4f80e19c4f8a99642f697d/tiktoken-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6600660f2f72369acb13a57fb3e212434ed38b045fd8cc6cdd74947b4b5d210", size = 1008075, upload-time = "2025-02-14T06:02:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/40/10/1305bb02a561595088235a513ec73e50b32e74364fef4de519da69bc8010/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e811743b5dfa74f4b227927ed86cbc57cad4df859cb3b643be797914e41794", size = 1140754, upload-time = "2025-02-14T06:02:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/40/da42522018ca496432ffd02793c3a72a739ac04c3794a4914570c9bb2925/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99376e1370d59bcf6935c933cb9ba64adc29033b7e73f5f7569f3aad86552b22", size = 1196678, upload-time = "2025-02-14T06:02:29.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/41/1e59dddaae270ba20187ceb8aa52c75b24ffc09f547233991d5fd822838b/tiktoken-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:badb947c32739fb6ddde173e14885fb3de4d32ab9d8c591cbd013c22b4c31dd2", size = 1259283, upload-time = "2025-02-14T06:02:33.838Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/b16003419a1d7728d0d8c0d56a4c24325e7b10a21a9dd1fc0f7115c02f0a/tiktoken-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:5a62d7a25225bafed786a524c1b9f0910a1128f4232615bf3f8257a73aaa3b16", size = 894897, upload-time = "2025-02-14T06:02:36.265Z" }, + { url = "https://files.pythonhosted.org/packages/7a/11/09d936d37f49f4f494ffe660af44acd2d99eb2429d60a57c71318af214e0/tiktoken-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b0e8e05a26eda1249e824156d537015480af7ae222ccb798e5234ae0285dbdb", size = 1064919, upload-time = "2025-02-14T06:02:37.494Z" }, + { url = "https://files.pythonhosted.org/packages/80/0e/f38ba35713edb8d4197ae602e80837d574244ced7fb1b6070b31c29816e0/tiktoken-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27d457f096f87685195eea0165a1807fae87b97b2161fe8c9b1df5bd74ca6f63", size = 1007877, upload-time = "2025-02-14T06:02:39.516Z" }, + { url = "https://files.pythonhosted.org/packages/fe/82/9197f77421e2a01373e27a79dd36efdd99e6b4115746ecc553318ecafbf0/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf8ded49cddf825390e36dd1ad35cd49589e8161fdcb52aa25f0583e90a3e01", size = 1140095, upload-time = "2025-02-14T06:02:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/4513da71cac187383541facd0291c4572b03ec23c561de5811781bbd988f/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156cb314119a8bb9748257a2eaebd5cc0753b6cb491d26694ed42fc7cb3139", size = 1195649, upload-time = "2025-02-14T06:02:43Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5c/74e4c137530dd8504e97e3a41729b1103a4ac29036cbfd3250b11fd29451/tiktoken-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd69372e8c9dd761f0ab873112aba55a0e3e506332dd9f7522ca466e817b1b7a", size = 1258465, upload-time = "2025-02-14T06:02:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/de/a8/8f499c179ec900783ffe133e9aab10044481679bb9aad78436d239eee716/tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95", size = 894669, upload-time = "2025-02-14T06:02:47.341Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/a1/822ceef22d1c139cffebe4b1b660cfaa10253d5c770aa2598dc8e9497593/uuid_utils-0.16.0.tar.gz", hash = "sha256:d6902d4375dfba4c9902c736bb82d3c040417b67f7d0fa48910ddfdb1ac95de7", size = 42596, upload-time = "2026-05-19T07:44:23.28Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/4c/b4cf43a5d22bcdb91727acdf54be0d78e83e595b73c5a9a8a4291875f059/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:727fae3f0682191ec9c8ce1cd0f71e81b471a2e26b7c5fd66712fc0f11640aa0", size = 562183, upload-time = "2026-05-19T07:45:02.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fb/4b0d1c4b5e9f8679ca41b9cdbce5749e1d5db3d3d42a07060d6ce61ac583/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66a9c8cedf7695c28e700f6a66bde0809c3b2e0d8a70968be7bfd47c908952e5", size = 289018, upload-time = "2026-05-19T07:44:07.726Z" }, + { url = "https://files.pythonhosted.org/packages/de/43/2dc6c7401c8fab86e46b0b33ada6dcfde949b2fd48877ba6f880862be80e/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9152bff801ec2ccf630df06d67389090a2c612dea87fbf9a887ab4b222929f6f", size = 326171, upload-time = "2026-05-19T07:45:25.186Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/48f11fb91f36453611ca148bc441436f279870b1ec6b576dc5167fb6e680/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06fc7db470c37e5c1ab3fd2cd159697d6f8b279d7d23b5b96bd418b115f8caa9", size = 332222, upload-time = "2026-05-19T07:45:09.036Z" }, + { url = "https://files.pythonhosted.org/packages/30/cb/b2b49528521e4a097f129e8bf7850a26f00af46afba778832cf3458a5c00/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e1a1f57fe3631e164dad27b24aa81267810e20575f705af3b0fa734f3a21247", size = 444801, upload-time = "2026-05-19T07:45:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b3/a28d9c6f7c701dfe01c8020b30e33899a28eb9e4d056b07e7388f50ebf67/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee392fe59808a731b7b6bf4d453fb6e833774921331cceae5f254d1e9c5b97d", size = 325594, upload-time = "2026-05-19T07:44:44.682Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/e1ff41dc44966e396ead86e104ba21b35ddb07ff7a64bb55013074ee77fe/uuid_utils-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2e981b1258db444df4cf4bf4c79673570d081d48d35f22d0f86471e0ad795c5", size = 349312, upload-time = "2026-05-19T07:45:15.582Z" }, + { url = "https://files.pythonhosted.org/packages/ed/57/fb19b7951f66a46e03bd1943a61ee9d59c83e994e56e8c97d79aff1f0e47/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbb92feb4db08cd76e27b4d3b1a82bfde708447317150c614eb9f761a43b387e", size = 502115, upload-time = "2026-05-19T07:43:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8e/9a129c469b7b77afb62da5c6b7e92591073b845bd0c3108c0d0aa65389fb/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c3c5afaaa68b1d6393d653e9fc93a2fde9da1681da01f74b4593f41d31fb5f1", size = 607433, upload-time = "2026-05-19T07:44:11.675Z" }, + { url = "https://files.pythonhosted.org/packages/4a/56/2ef71fad168cc3d894f7094fa458086c093635d7835381c91470b19c9ad3/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:38126b353527c5f001e4b24db9e62351eb768d0367febcd68100a4b39a035109", size = 566076, upload-time = "2026-05-19T07:44:35.453Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/68e60ea053ca30f35df877b96001331398140d5c4983561affa1350331b1/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41a67e546d9adf11c4e4cb5c8e81f000f8b1f000c17912ced089b499855719a5", size = 530645, upload-time = "2026-05-19T07:45:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/42/19/b521f7d73094fca4c0c44002f4a42bfcbcf0b770fdc3c4b9a596dda25734/uuid_utils-0.16.0-cp312-cp312-win32.whl", hash = "sha256:52d2cc8c12a3466cd1727883e0746d8bad5dddd670369eb553ba17fdc3b565ca", size = 168887, upload-time = "2026-05-19T07:45:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/87/1f/4126c3ccbc2d98a613664e55f6ab6d7bd4b98424a04486e4fcc76549af15/uuid_utils-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97625e5edfda8b118160ce1e88756f92b1635775f836c168be7bf10928d97fa", size = 174607, upload-time = "2026-05-19T07:43:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/74/62/b83ccc8446ae39dcc0bda2cb3b525b6af6a2036383afe1d1d5fe7b234c2c/uuid_utils-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:baf79c8050eb784b252dd34807df73f61130fe8676b61231baccab62530f20ec", size = 173021, upload-time = "2026-05-19T07:45:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/60/9b/74c1f47a9b4f138a254e51528e5ffaeba6bf99ecead9f0c4b6fccccfbfcb/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d34cf9681e8892fad2a63e393068e544505408748cd8bf0c3517d753a01528d4", size = 563166, upload-time = "2026-05-19T07:44:10.494Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1c/009e37b70f1f0ff17e7103a36bafde33d503d9ea7fe739761aa3e3c9fde6/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0681d1bdb7956e0c6d581e7601dabcfb2b08c25d2a65189f4e9b102c94f5ff46", size = 289529, upload-time = "2026-05-19T07:43:54.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5e/e0323d54321166639eb2be5e8a464f5cb0fc04d72d91f3e78944bb6a1da8/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed45fb8732d216426227096b55accbb87cba57febc86a044d90780b090eb99d0", size = 326328, upload-time = "2026-05-19T07:45:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a3/046f6cb958467c3bf4a163a8a53b178b64a62e21ed8ad5b2c1dacb3a2cfc/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b617a334bb01ef2ff8c22900f5a14125eb9063f602131494cc9dc59519beaa5b", size = 332322, upload-time = "2026-05-19T07:43:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/01914e3949744db7acd0006885e5542fbebb6e39114857d007d29b3265c2/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a750d8aeb8ae880aa9a2529606bde0e994bcc7448730c953107f357a28e6102e", size = 445787, upload-time = "2026-05-19T07:45:36.102Z" }, + { url = "https://files.pythonhosted.org/packages/14/ef/f6908f41279f205d70c8a0d5dcb25dd6802741d7f88e3f0123453c3584d3/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a250e111903c4368745fce5ac2aa607bd477c62d3307e45347338fdb64b38e0", size = 324678, upload-time = "2026-05-19T07:45:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/11/4a/bf841ba90f829c7779d82155e0f4b88ef6726ccc25507d064d50ac2cd329/uuid_utils-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:95b7f480010ea98a29ee809857a98aa923008c68129af1b39244adccff7377fb", size = 349704, upload-time = "2026-05-19T07:44:47.172Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3b5c60172b8c57bf4ca485484b8e4edef550ca324f9287f1183be97422e2/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:420aa3ca403cedb73490b6ea3aeefeea7e0455f5ce60bbf856390ee872ae3306", size = 502456, upload-time = "2026-05-19T07:45:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/88/bf/3da8d497af80fd51d8bf85551c77ede67f07825924ec5987bf9b6031014a/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b8a9a7b1065a12d40f2cc25b7d705ab34954cc57095034367bca39ebcf4a876b", size = 607727, upload-time = "2026-05-19T07:44:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/7c8cf03ec15cd6f40e4cbab81b2b4a625461327f68c7971e54723280ec3e/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f235ac5827d74ac630cc87f29278cdaa5d2f273613a6e05bbd96df7aa4170776", size = 566204, upload-time = "2026-05-19T07:44:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5f/af955feae69cce7fd2121ca3f790ff4b85ad2e17b2149546f50753e1a047/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c8083284488b84ad178e74add64cfd1e74e8be5e30821e5acbc5019281c658b0", size = 529986, upload-time = "2026-05-19T07:45:57.85Z" }, + { url = "https://files.pythonhosted.org/packages/10/cf/3fec757e51bef10eb41ae8075f5442c60e85ff456b42d16a3063f5dc6c80/uuid_utils-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:27a071a899ba46a551d6524dbbc5a98b88be176d0f55ddf72cf71c005326ac10", size = 98683, upload-time = "2026-05-19T07:44:16.369Z" }, + { url = "https://files.pythonhosted.org/packages/40/a7/cd1adbea7ef882a70db064c00cd93b12e11027b4cdd7ffd79e95c35fc3e3/uuid_utils-0.16.0-cp313-cp313-win32.whl", hash = "sha256:924a8de04460e4cf65998ad0b6568084f7c51740ebd3254d07a0bcde35a84af6", size = 168822, upload-time = "2026-05-19T07:44:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/74/99/617ceb9e3a95b23837012740979baf71afad723b70daf34862da3f7c17a1/uuid_utils-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:5279bc7ab3c6683f1c67314695bee14d869015acbbc677bdb0015190fe753d16", size = 174967, upload-time = "2026-05-19T07:44:56.022Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d8/148ae707bfc36d482e39db679c86b81bdce264d4feb9df5d40a03b7687e3/uuid_utils-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:61a9c4c26ad12ac66fa4bfd0fdb8494724fe7a5b98a9fcd43e78e2b388663dbb", size = 173142, upload-time = "2026-05-19T07:43:50.171Z" }, + { url = "https://files.pythonhosted.org/packages/21/05/ca6d60705e71fdeaa3431dad94e279a8213c5573cb2925e1aabf3dc0330a/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73486b6aa3f755a6c97000f5ea67e7ac78d6df89bf22980789a1e943e24b74f0", size = 564408, upload-time = "2026-05-19T07:44:38.351Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/b9a0462c38535c1662acb1025768e2d626bee5ce9e1790bad6b5381162ea/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f1614572fd9345cdc3dde3f40c237345719fabca1aa87d2d87b321d523cfa34d", size = 289923, upload-time = "2026-05-19T07:45:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/a53afeef1a56051551a0f5a801e4bce411dd73c6a8c99bad16902651256d/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9346ce6eb1fbd8b03a6b331d66016afcb4edcdff6eac708e21391600529a016a", size = 325762, upload-time = "2026-05-19T07:45:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/72/ca/4462a4f36365d7ee72d41e05e6bcfe127e861b073ab37c25b2c8a518317c/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0fc6eb3fd821466fbab69cf356c6ec2b7327266bbbc740a2eb57c77c4bef965", size = 332359, upload-time = "2026-05-19T07:45:34.886Z" }, + { url = "https://files.pythonhosted.org/packages/c5/67/9d3373fa7c5a746fdecc64e30caf915c29eb632203508d87676f9243ed03/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13a797e5e8f0dadc18351a5aa013815ddac25dce6864072a539d510910c95f71", size = 445483, upload-time = "2026-05-19T07:44:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/57/08/ce01aa6d897fc7f875844fe58cad0a542c8ebf089d9242b654b56260ecb8/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57c3583b1f1c00a94f59726a5e2b988fa209221143919a1af5c2fc24e318fc98", size = 326281, upload-time = "2026-05-19T07:44:59.677Z" }, + { url = "https://files.pythonhosted.org/packages/76/ef/2c719b2c26bb5b5e5061a1435c11ad2bd33ac3cd6d4cd0c7c3ac1d3396ed/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:caac9c8b1d50e8fbddc76e93bfefbef472978eb45adbfdb6289d578816992953", size = 350809, upload-time = "2026-05-19T07:45:28.076Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9b/c1ed447328b32229cca38ac4c62d309eab006e5e9c4020e2056a175bc607/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:91db59bad97ed2b9d2c6ed25082fe9762b2c422e694fe06786b28cf4e776ac4c", size = 502088, upload-time = "2026-05-19T07:44:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e0/8442f4efe7bde72f0b4ae5f675d0c7fbe209ad0b54718b8ddf43c46c6fae/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:41985e342a30e76366a8becc60bbdb07d72cd1b86ec657b1f31654e9fb1baada", size = 607631, upload-time = "2026-05-19T07:44:19.384Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/9a9fa261edf4c972f28ae83421377e3ab8dbd0bd7db58fd316e782d09a3b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1b0dcedf9266bf34a54d5cbe78648eaa627e02352f2a6923ed647530aea2f661", size = 567618, upload-time = "2026-05-19T07:43:58.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/1bcfdb9d539bd42736dd6076470a42fbb5db23f79712c0a06aa0a3752f7b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26fe23ab60f05de4ad70aaa5b6a4c2a7bbd43055e3dd6f6b31efba0532ac9c71", size = 530971, upload-time = "2026-05-19T07:45:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/24/0c/18945f417d6bb4d0dd2b7652fe36c58c4e83bcf593b9b326b83aa40b853a/uuid_utils-0.16.0-cp313-cp313t-win32.whl", hash = "sha256:7f8cf49c05d58523a0f977cb7f11afc05791a0fa164d7303b8365a34750638e7", size = 169369, upload-time = "2026-05-19T07:44:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/c0eb0c3fab2ed80d706369b750029143b53126809b77b36bcbb77da66bab/uuid_utils-0.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e99f9a8b2420b228faba23a637e96efaf5c6a678b2e225870f24431c82707f50", size = 175384, upload-time = "2026-05-19T07:45:56.623Z" }, + { url = "https://files.pythonhosted.org/packages/b7/77/50ac87b6e18b1c686f700aa38c9471a990683c6a955f71ac1a6677ed8145/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6853b627983aa1b4fd95aa52d9e87136eb94a7b3b7de0fbb1db8a498d457eeec", size = 564108, upload-time = "2026-05-19T07:43:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/65046676de246bb5334d9f58aa96d2feb9fc347fda3556aaff7da1c2fc7a/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f44b65ae0c329843817d9c90e36a7a3c677b413bf407c99e67db874dac49dad3", size = 289967, upload-time = "2026-05-19T07:45:38.886Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/54fa988606a15dfd2028e925d8eb9c3ee6edbf1eb7692a67b37282880b56/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de8a365795a76f347f5622621c2bee543cffa0c70949f3ee093bdefc9d926dcc", size = 325835, upload-time = "2026-05-19T07:44:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1b/50622f967ceacea1f89fd065d9bfd395b51acb02cfb0a4ddc8fa9ff0c983/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:426a8c9af90242d879706ccf29da56f0b0712e7739fb0bbe16baacabc75596e2", size = 332607, upload-time = "2026-05-19T07:43:42.42Z" }, + { url = "https://files.pythonhosted.org/packages/12/f5/4059706be6617e2787e375ea52994ce3c3fa3920b7d4a9c8ebf7895681a5/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:833bc4b3c3fc24be541f67b01b4a75b6b9942a9b7137395b4eb35435948bd6da", size = 444287, upload-time = "2026-05-19T07:43:37.106Z" }, + { url = "https://files.pythonhosted.org/packages/65/d5/f44b2710563da687a368f0ce4dcbd462dfb6708bcd46439d831991d595c7/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb5252d7c00d586077f10e169d6e6d0b0d0f806d8a085073f0d19b4737aef4e", size = 324949, upload-time = "2026-05-19T07:45:33.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a7/a69e859e37d26c5603f0bc0ae481860f691224f140e5a832f325b804770d/uuid_utils-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b3377ce388fd7bf8d231ec9d1d4f58c8e87888ddea93581f60ed6f878a4f722", size = 349651, upload-time = "2026-05-19T07:43:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/db/73/4139cd3ca7b81ea283c1c8769373e9b2008241c0744a8ffb25f0a1b31325/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:12b6310beb38adc173ec5dc89e98812fd7e3d98f87f3ef01d2ea6ecb5d87994f", size = 502326, upload-time = "2026-05-19T07:45:40.292Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8c/858101583fbad1b3fa04da88b1f7170836aa0f00b4cb712063325c44466d/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a49b5a75497643479c919e2e537a4a36224ac3aaa0fada61b75d87024021ac3e", size = 607689, upload-time = "2026-05-19T07:44:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/8f3d54a4763dd91ebd0f3d7b0c2ec434e4e0b1fc667b03a44d611a465ec6/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:63bfdf00be51b6b3b79275d6767d034ea5c7a0caa067a35d72861284100cb60a", size = 566214, upload-time = "2026-05-19T07:44:53.519Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/4c9a8d9baaa243c7902d84dbba4d51b1ab51c379c66d3fd6368ff6933ecf/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7525bc59ac4579c32317d2493dd42cf134b9bb50cd0bc6a41dd9f77e4740dde6", size = 529989, upload-time = "2026-05-19T07:44:43.141Z" }, + { url = "https://files.pythonhosted.org/packages/6d/13/d32cea997f880cedde415730ce0e872ebfd7a040155ae0bbda70eccd208e/uuid_utils-0.16.0-cp314-cp314-win32.whl", hash = "sha256:fbcac6e6710aa2e4bfbb81762758e01470dc56d5048ba4253acc77c9833568ff", size = 169146, upload-time = "2026-05-19T07:45:46.655Z" }, + { url = "https://files.pythonhosted.org/packages/1c/19/9fc55172d8fe59e1f27a14d598b427fa508a7ebb35fa7b7b99c24fa0ef13/uuid_utils-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:d23fcaf37368a1647319187ef6f8b741bf079f033065899bc2d00a44b0a1214a", size = 175364, upload-time = "2026-05-19T07:45:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/fcd9226b715c5aa0638fcdd6deaf0de6c6c3c451c692cd76bfca810c6512/uuid_utils-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:ea3265f8e2b452a4870f3298cb1d183dc4e36a3682cbb264dbe46af31267e706", size = 173268, upload-time = "2026-05-19T07:44:31.19Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/97ec9af95e58b8187f2934008ffab26e1604d149e34fe01c388b0543a24f/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:99f8420c3ed59f89a086782ac197e257f4b1debb4545dffa90cf5db23f96c892", size = 564464, upload-time = "2026-05-19T07:44:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/e4082f407484ac28923c0bf8e861e71d277118d8b7542d0a350340e45350/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:259bab73c241743d684dcc3507feb76f484d720545e4e4805582aeff8e19700b", size = 290087, upload-time = "2026-05-19T07:44:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/43/c5c5f273c0ff889f20f10344784f9197dd00eb81ccc294330d4b949fea7e/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:897e8ef0dc5e4ac0b17cf9cae84bb41e560d806280ec5b93db7475b504022105", size = 325532, upload-time = "2026-05-19T07:43:47.508Z" }, + { url = "https://files.pythonhosted.org/packages/13/7f/669aa899ab5378374d28a28231e6978f739921a1af394c7ebd6cc86e2639/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c5af79cde16a7600dfccb7d431aec0afd3088ff170b6a09887bf3f7ab3cc7c81", size = 332209, upload-time = "2026-05-19T07:43:51.528Z" }, + { url = "https://files.pythonhosted.org/packages/2b/57/a2a32406d79a222794ef98a19254fd9a81a029a0f32d7740fba9873bff1f/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bece1a6f677ca36047442c465d8166643eed9818b9e43e0bf42d3cf73e92dcff", size = 445507, upload-time = "2026-05-19T07:44:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/85459a35bfa7d73e79acbc4eab1cf6aa6e4d9d022c3260ed9dea539c7f0b/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3444498e7b099499c8a607d7771377020fa55f7274e46f54106af19f752d7", size = 326154, upload-time = "2026-05-19T07:45:23.587Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/e965efdbb503ed14d6e57aec1a22b98326ed24cc2fb48e750c4d192267a0/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:542098f6cb6874aebeff98715f3ab7646fbe0f2ffb24509ca372828c68c4ed0e", size = 350905, upload-time = "2026-05-19T07:44:36.957Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/4321867888a783d03b7c053c0b68ca45d03974d86fcebf44d4ec268db397/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7207b25fe534bcf4d57e0110f90670e61c1c38b6f4598ba855af69ab428fc118", size = 502098, upload-time = "2026-05-19T07:44:17.696Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/914a47bf42479bff0ce3e1fa1cbe3585354708edc928e27687cf91de9c26/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:16dc5c6e439f75b0456114e955983e2156c1f38887733e54d54205d3005223e4", size = 607032, upload-time = "2026-05-19T07:44:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/85/4c/2abacd6badba61a047eaa39c8347656229d12843bd9bbe4906daa6dc752c/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6d3ee32c57898d8415242b08d5dd086bc4f7bcbbb3fc102ef257f3d793eb294", size = 567664, upload-time = "2026-05-19T07:45:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/53/1f/9d1a09521276424da19dc0d74456aed3311170fec181b28fa6acba45d963/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7555f120a2282d1901c9a632c2398a614101af4fe3f7c8114aa0f1d8c1978855", size = 530996, upload-time = "2026-05-19T07:45:44.229Z" }, + { url = "https://files.pythonhosted.org/packages/b4/22/14dbedb6b61f492d5524077fd10bbfb137583b0f0aafa6cd870ccb43f39a/uuid_utils-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:756575d082ea4cb7d2f923d5b640c0efe7c82573aab49220c4e09b62d13737ff", size = 169358, upload-time = "2026-05-19T07:45:05.146Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/a636806c98401a1108f2456e9cc3fa39a618145bfb1d0860c57203159cfe/uuid_utils-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:aa50261a83991dbb570a00573741455bd8f3249444f7329e5bdcd494799d1504", size = 174813, upload-time = "2026-05-19T07:45:59.579Z" }, + { url = "https://files.pythonhosted.org/packages/75/12/3823742459d87a100deb24bb6b41692aa961b267abd130fa7739cdf7d409/uuid_utils-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:22a17e93a371d850ffce8fcdbacc2239f890efe73aa3262b6170c1febc08afe1", size = 171733, upload-time = "2026-05-19T07:45:29.283Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] + +[[package]] +name = "zstandard" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713, upload-time = "2024-07-15T00:15:35.815Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459, upload-time = "2024-07-15T00:15:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707, upload-time = "2024-07-15T00:15:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545, upload-time = "2024-07-15T00:15:41.75Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533, upload-time = "2024-07-15T00:15:44.114Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510, upload-time = "2024-07-15T00:15:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973, upload-time = "2024-07-15T00:15:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968, upload-time = "2024-07-15T00:15:52.025Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179, upload-time = "2024-07-15T00:15:54.971Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577, upload-time = "2024-07-15T00:15:57.634Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899, upload-time = "2024-07-15T00:16:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964, upload-time = "2024-07-15T00:16:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398, upload-time = "2024-07-15T00:16:06.694Z" }, + { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313, upload-time = "2024-07-15T00:16:09.758Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877, upload-time = "2024-07-15T00:16:11.758Z" }, + { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595, upload-time = "2024-07-15T00:16:13.731Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975, upload-time = "2024-07-15T00:16:16.005Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448, upload-time = "2024-07-15T00:16:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269, upload-time = "2024-07-15T00:16:20.136Z" }, + { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228, upload-time = "2024-07-15T00:16:23.398Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891, upload-time = "2024-07-15T00:16:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310, upload-time = "2024-07-15T00:16:29.018Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912, upload-time = "2024-07-15T00:16:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946, upload-time = "2024-07-15T00:16:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994, upload-time = "2024-07-15T00:16:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681, upload-time = "2024-07-15T00:16:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239, upload-time = "2024-07-15T00:16:41.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149, upload-time = "2024-07-15T00:16:44.287Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392, upload-time = "2024-07-15T00:16:46.423Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299, upload-time = "2024-07-15T00:16:49.053Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862, upload-time = "2024-07-15T00:16:51.003Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578, upload-time = "2024-07-15T00:16:53.135Z" }, +] diff --git a/vimax b/vimax new file mode 100755 index 0000000..1483ed5 --- /dev/null +++ b/vimax @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE="${BASH_SOURCE[0]}" +while [[ -L "$SOURCE" ]]; do + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" +done +ROOT="$(cd -P "$(dirname "$SOURCE")" && pwd)" + +usage() { + cat < Resume a specific existing session + vimax tui --new-session Same as: vimax tui new + vimax tui --session Same as: vimax tui resume +EOF +} + +if [[ $# -lt 1 ]]; then + usage + exit 2 +fi + +command="$1" +shift + +case "$command" in + tui) + tui_args=() + case "${1:-}" in + new) + tui_args+=("--new-session") + shift + ;; + resume|old) + shift + if [[ -n "${1:-}" && "${1:-}" != --* ]]; then + tui_args+=("--session" "$1") + shift + fi + ;; + --new-session) + tui_args+=("--new-session") + shift + ;; + --session) + if [[ -z "${2:-}" ]]; then + echo "error: --session requires a session id" >&2 + exit 2 + fi + tui_args+=("--session" "$2") + shift 2 + ;; + "") + ;; + --help|-h|help) + usage + exit 0 + ;; + *) + echo "error: unknown tui mode: $1" >&2 + usage >&2 + exit 2 + ;; + esac + if [[ $# -gt 0 ]]; then + tui_args+=("$@") + fi + if [[ ! -x "$ROOT/ui/node_modules/.bin/tsx" ]]; then + echo "error: ui dependencies are missing. Run: cd $ROOT/ui && npm install" >&2 + exit 2 + fi + exec "$ROOT/ui/node_modules/.bin/tsx" "$ROOT/ui/src/cli.tsx" ${tui_args[@]+"${tui_args[@]}"} + ;; + --help|-h|help) + usage + ;; + *) + echo "error: unknown command: $command" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/vimax_benchmark/artist_extreme_weather_typeA.json b/vimax_benchmark/artist_extreme_weather_typeA.json new file mode 100644 index 0000000..f3cf63d --- /dev/null +++ b/vimax_benchmark/artist_extreme_weather_typeA.json @@ -0,0 +1,88 @@ +{ + "story_overview": "A single dedicated adult painter pushes through increasingly extreme weather—scorching desert heat, a violent coastal storm, and a whiteout blizzard—to complete one resilient landscape painting, culminating in a final, hard-won finishing stroke.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Bright midday desert plain under a blazing sun. In the center foreground stands the same adult artist: a 32-year-old woman with warm brown skin, dark curly hair in a low bun, a small crescent-shaped scar on her left eyebrow, and rectangular black eyeglasses. She wears a sage-green canvas field jacket (buttoned), a cream crew-neck T-shirt, dark indigo straight-leg jeans, tan lace-up work boots, and a muted red scarf wrapped once around her neck; no skin-revealing clothing. She holds a wooden palette in her left hand and a medium flat brush in her right, facing a sturdy tripod easel with a stretched canvas. The canvas shows the first faint underpainting lines of a wide horizon. Heat haze ripples above cracked earth; distant mesas blur in shimmering air. Hard shadows, high contrast, dust texture on her boots and jacket hem.", + "video_prompt": "Eye-level medium-wide shot with a 35mm lens from slightly left of the easel. The artist squints behind her glasses and begins laying confident underpainting strokes while a hot gust lifts fine sand past her legs. The scarf flutters subtly; she steadies the easel with her hip without changing her outfit or identity. The camera makes a slow 10% push-in as heat shimmer warps the distant mesas, emphasizing the harsh sunlight and gritty textures." + }, + { + "shot_id": 2, + "first_frame": "Low angle close-up near the easel’s lower clamps, looking upward. The same artist’s tan work boots are planted on cracked desert ground; a corner of her sage-green jacket and dark jeans frames the left edge. The wooden easel legs dig into sand; a small metal cup of water and a cloth rag sit on a flat stone. The sun is a bright white disc overhead; harsh glare reflects off the metal cup. Fine grains of sand collect around the easel feet.", + "video_prompt": "Low angle close-up with a 50mm lens aimed up from ground level. A stronger gust pushes sand across the frame; the artist’s boot shifts half a step to brace the easel, and her hand (sleeve of the same sage-green jacket visible) tightens a clamp. The metal cup trembles slightly, water rippling but not spilling. The camera remains locked while the environment moves, highlighting stability against the wind." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder view from behind the artist’s right shoulder, framing the canvas prominently. The same woman (scar on left eyebrow visible in profile, black rectangular glasses) leans in slightly. Her muted red scarf is tucked neatly; jacket buttoned. The canvas now has a simple block-in: warm ochres and pale blues forming desert sky and mesa shapes. The background is a wide, sun-bleached expanse with mirage-like distortion.", + "video_prompt": "Over-the-shoulder medium shot with a 70mm lens, camera positioned just behind her right shoulder. She mixes paint on the wooden palette, then paints a clean horizon line and broad sky gradient. Heat shimmer intensifies and the light flares slightly at the top edge; she pauses to wipe her brow with the back of her jacket sleeve (no exposed skin beyond hands). The camera performs a gentle rightward slide to reveal more of the canvas progress." + }, + { + "shot_id": 4, + "first_frame": "Abrupt environmental shift: a windswept rocky coastline at late afternoon under heavy slate clouds. Sea spray hangs in the air. The same artist (32-year-old woman, crescent scar on left eyebrow, black rectangular glasses, dark curly hair in low bun) stands beside her easel on wet rock, wearing the exact same outfit: sage-green field jacket, cream T-shirt, dark indigo jeans, tan work boots, muted red scarf. The canvas is strapped to the easel with visible elastic bands to resist wind. The ocean churns behind her; light is cooler and directional, with wet reflections on rock.", + "video_prompt": "Wide shot with a 24mm lens at waist height, camera facing the artist and easel with the stormy ocean behind. A strong coastal gust whips mist across the scene; she leans forward, tightening a strap around the canvas, then resumes painting with quick, controlled strokes. Waves surge and retreat in the background; the camera slowly tilts up from the wet rock to the artist’s focused face, emphasizing the drastic lighting and weather change while her identity and clothing remain unchanged." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Tight portrait close-up of the artist’s face in the coastal storm. Her black rectangular eyeglasses are speckled with sea mist; the small crescent scar on her left eyebrow is clearly visible. Her muted red scarf flutters near her chin; sage-green jacket collar is up but still modest and fully covering. The background is softly blurred gray ocean and cloud. Cool, diffuse lighting; droplets cling to her lenses and hairline.", + "video_prompt": "Eye-level close-up with an 85mm lens. She exhales steadily, then uses a clean cloth to wipe mist from her glasses in one deliberate motion and looks back to the canvas with calm determination. Fine spray continues to hit her jacket and scarf. The camera holds steady with minimal breathing, letting the micro-movements and droplets convey intensity." + }, + { + "shot_id": 6, + "first_frame": "Top-down angle looking at the palette and hands. The same artist’s hands hold a wooden palette and brush; sleeves of the sage-green jacket are rolled down, buttoned at the wrist. On the palette: thick paint—deep ultramarine, titanium white, warm ochre, and a touch of gray-green. A few drops of seawater bead on the palette surface. The rocky ground below is dark and slick with water.", + "video_prompt": "Overhead close-up with a 35mm lens, camera directly above the palette. She mixes ultramarine and white into a stormy sky tone, then taps excess moisture off the brush against the palette edge. A gust sends a few droplets across the paint, slightly thinning a small area; she compensates by adding more pigment. The camera gently rotates a few degrees clockwise to emphasize dynamic weather without losing the constant identity cues (same sleeves, same hands, same tools)." + }, + { + "shot_id": 7, + "first_frame": "Side profile medium shot on the coastline. The artist stands to the right of frame, easel to the left. The ocean is rough; a wave breaks in the midground, throwing spray. The same outfit and features are consistent: sage-green jacket, cream T-shirt, dark indigo jeans, tan boots, muted red scarf, black rectangular glasses, scar on left eyebrow. The canvas shows a clearer composition now: a horizon line, storm clouds, and the suggestion of surf. Cool, dramatic lighting with occasional bright breaks in cloud.", + "video_prompt": "Medium shot with a 50mm lens from the artist’s left side. She paints rapidly, then pauses to shield the canvas with her body as a burst of spray arrives; the elastic straps hold firm. She nods as if satisfied with the resilience of the setup and adds a decisive dark stroke to define a cloud edge. The camera performs a short lateral truck left-to-right, keeping her profile and the canvas in frame while the surf churns behind." + }, + { + "shot_id": 8, + "first_frame": "Extreme environment shift: nighttime urban rooftop under heavy rain. Neon signage from distant buildings casts magenta and cyan reflections on puddles. The same artist stands under a small, clear rain hood integrated into her sage-green jacket (still the same jacket, now hood up), scarf visible, glasses on, hair bun tucked. She uses a clip-on LED lamp attached to the easel, illuminating the canvas. The canvas is now a composite: desert horizon tones blended with stormy sea grays, suggesting a thematic series. Raindrops streak diagonally; wind pushes the rain.", + "video_prompt": "Wide shot with a 28mm lens, slightly high angle looking down at the rooftop setup. Rain falls hard; she adjusts the clip-on LED to reduce glare, then continues painting, making careful highlights while water ripples in rooftop puddles. Neon reflections shimmer and shift as the camera slowly arcs around her 20 degrees, emphasizing the drastic lighting change while her identity, clothing, and tools remain constant." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Daytime alpine ridge in a whiteout blizzard. Visibility is low; everything is desaturated with pale gray sky and blowing snow. The same artist stands firm, bundled only in her exact consistent outfit: sage-green jacket (hood up), cream T-shirt beneath, dark indigo jeans, tan work boots, muted red scarf, black rectangular glasses; crescent scar on left eyebrow visible when she turns slightly. Snow clings to her shoulders and hood. The easel is anchored with a compact sandbag; the canvas is partially covered by a transparent protective sheet clipped at the top.", + "video_prompt": "Eye-level wide shot with a 24mm lens facing the artist head-on. Wind drives snow across the frame; she tightens the clips on the protective sheet, then lifts it briefly to paint, keeping the canvas shielded between strokes. The camera makes a slow push-in through the swirling snow, increasing tension while maintaining clarity on her face and outfit details." + }, + { + "shot_id": 10, + "first_frame": "Close-up on the canvas surface in the blizzard, angled from the right side. The brush tip touches thick paint; the scene on the canvas has evolved into a unified triptych-like landscape: warm desert hues at the lower left, stormy sea and sky at center, and icy whites at the upper right. Snowflakes land on the protective sheet; a few melt into droplets along the edge. The artist’s gloved hands are NOT present (to avoid introducing new wardrobe elements); her bare hands are visible, sleeves of the same jacket covering wrists.", + "video_prompt": "Macro close-up with a 100mm lens, camera angled 30 degrees to the canvas. She adds careful, bright highlights for snow glare using short, controlled strokes, then gently dabs a small droplet off the canvas edge with a cloth. Snow continues to pepper the protective sheet overhead. The camera remains steady, letting the paint texture and small movements convey precision under extreme conditions." + }, + { + "shot_id": 11, + "first_frame": "High angle medium shot looking down from slightly behind and above the artist’s left shoulder. The blizzard swirls; the easel stands firm with the sandbag visible at its base. The artist’s muted red scarf is tucked, jacket hood up, glasses catching soft gray light; the scar on her left eyebrow is partially visible as she glances toward the horizon. The canvas appears nearly finished, with cohesive color transitions linking desert, storm, and snow themes.", + "video_prompt": "High angle medium shot with a 35mm lens, camera positioned behind and above her left shoulder. She steps half a pace back to assess, then steps forward again and mixes a final neutral tone on the palette. The wind gusts harder; she steadies the easel with one hand and positions the brush for a final mark. The camera subtly tilts down to emphasize the painting’s completion and the physical anchoring of the setup." + }, + { + "shot_id": 12, + "first_frame": "Front-facing medium close-up in the blizzard with the finished canvas held upright on the easel. The artist—same 32-year-old woman with warm brown skin, black rectangular glasses, crescent scar on left eyebrow, dark curly hair in a low bun under the hood—stands beside the painting in her unchanged modest outfit: sage-green canvas jacket, cream T-shirt, dark indigo jeans, tan work boots, muted red scarf. Snow swirls around them, but the canvas image is clear: a resilient, blended landscape bridging sun, storm, rain-neon reflections, and snow. Soft, diffuse light; snow crystals sparkle faintly.", + "video_prompt": "Eye-level medium close-up with a 50mm lens, camera centered on the artist and the finished painting. She makes one final, deliberate finishing stroke at the canvas edge, then lowers the brush and allows herself a small, calm smile of relief. The wind continues to blow snow past the lens; the camera holds steady for a beat, then performs a subtle 5% pull-back to frame both her and the completed work as the story’s climax and resolution." + } + ] + } + ], + "metadata": { + "theme_key": "artist_extreme_weather", + "theme_description": "An artist painting in various extreme weather conditions", + "consistency_type": "Type A", + "requested_scenes": 3, + "requested_shots": 12 + } +} diff --git a/vimax_benchmark/athlete_training_conditions_typeA.json b/vimax_benchmark/athlete_training_conditions_typeA.json new file mode 100644 index 0000000..184c6ac --- /dev/null +++ b/vimax_benchmark/athlete_training_conditions_typeA.json @@ -0,0 +1,113 @@ +{ + "story_overview": "A single adult athlete follows a focused training plan across radically different weather and terrain, adapting technique and mindset until a final, demanding summit run proves their growth.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Early morning urban track under pale sunrise. Center frame: a 27-year-old male athlete with warm brown skin, short curly black hair, a neat trimmed beard, and a small crescent-shaped scar on his left eyebrow. He wears a forest-green zip-up track jacket with reflective piping, charcoal gray athletic pants, and white running shoes with a distinctive lime-green stripe; a black sports watch on his left wrist. He stands at lane one beside a starting line, stretching calves. Background: empty bleachers, a scoreboard, faint mist hovering above the track. Crisp, cool lighting, realistic textures on rubber track granules.", + "video_prompt": "Eye-level wide shot on a 24mm lens from the inside curve of the track. The athlete finishes stretching, steps to the line, takes two controlled breaths, then jogs forward into an easy warm-up; light mist drifts sideways and the sunrise brightens slightly, creating gentle highlights on the reflective piping." + }, + { + "shot_id": 2, + "first_frame": "Same athlete and exact outfit at the same track but now framed tight: his hands adjust the forest-green jacket zipper and he glances at the black sports watch. The scar on the left eyebrow is clearly visible. Background is softly blurred: lane markings and bleachers. Cool sunrise rim light along his cheek and jacket shoulder, shallow depth of field.", + "video_prompt": "Eye-level close-up on an 85mm lens, static camera. He taps the watch to start a timer, exhales, and nods with determination; a faint breeze ripples the jacket fabric and condensation in the air curls past his face." + }, + { + "shot_id": 3, + "first_frame": "Top-down view of the urban track’s first curve. The athlete (same face, scar, beard, hair, and same forest-green jacket/charcoal pants/white shoes with lime stripe) runs along the inside lane. The rubber track texture is prominent, lane numbers crisp. Long morning shadows stretch behind him. The environment remains urban but this shot is a new composition with strong geometry.", + "video_prompt": "Overhead wide shot on a 20mm lens, slow lateral drift following his path. He increases cadence into a steady pace, arms swinging compactly; his footsteps trace the curve while the long shadows slide across lane markings, emphasizing rhythm." + }, + { + "shot_id": 4, + "first_frame": "Hard cut to a drastically different environment: a rain-soaked coastal boardwalk at dusk. The same athlete in the exact same outfit runs toward camera, shoes splashing through shallow puddles; lime-green shoe stripe flashes. Background: gray ocean, wet wooden planks, distant pier lights reflecting on puddles. Overcast sky, raindrops visible, moody cool-blue lighting with warm pinpoints from lamps.", + "video_prompt": "Low angle medium shot on a 35mm lens from boardwalk level. Rain falls steadily; he runs past a puddle, splashing water outward, then slightly adjusts stride to avoid a larger puddle; the camera holds position as he passes, droplets streaking across frame and reflections shimmering underfoot." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Hard cut to bright midday desert trail. Same athlete, same forest-green jacket/charcoal pants/white shoes with lime stripe, black watch, scar on left eyebrow. He runs on sandy singletrack between low scrub and sun-baked rocks. Heat haze distorts the far horizon; harsh sunlight creates sharp shadows. Dust clings lightly to the lower pant legs and shoe soles.", + "video_prompt": "Eye-level wide shot on a 28mm lens, gentle handheld feel. He jogs uphill through sand, feet sinking slightly; he shortens stride and pumps arms more, kicking a small plume of dust that trails behind; heat shimmer wavers in the background." + }, + { + "shot_id": 6, + "first_frame": "Desert environment, tighter framing on the athlete’s torso and feet. The lime-green stripe on the shoes is prominent as he steps over a small rock. Jacket fabric shows sun highlights; watch glints. Ground texture: loose sand, pebbles, cracked earth.", + "video_prompt": "Low angle close-up on a 50mm lens focused on feet and lower legs. He performs quick-footwork steps—two light taps, then a careful step over the rock—sending sand grains skittering; the camera tracks a short distance alongside to emphasize precision." + }, + { + "shot_id": 7, + "first_frame": "Hard cut to a windy grassy hillside under fast-moving clouds. Same athlete, unchanged outfit and facial features, runs laterally across frame. Tall grass bends in gusts; a lone tree in background leans slightly. Lighting is dramatic: sun breaks through clouds, creating rolling patches of brightness across the hill.", + "video_prompt": "Side-on tracking shot at waist height on a 35mm lens. The camera moves parallel with him as wind pushes his jacket; he leans subtly into the gusts, stabilizing with arms; cloud shadows race over the grass while intermittent sunlight flickers across his face and the eyebrow scar." + }, + { + "shot_id": 8, + "first_frame": "Hard cut to nighttime city street under winter slush and streetlights. Same athlete, same clothing, jogs past a crosswalk; breath forms visible vapor. Wet asphalt reflects amber streetlights and neon signage shapes (generic, non-branded). Snow piles line the curb; light flurries fall. He looks focused, watch visible on left wrist.", + "video_prompt": "Eye-level medium shot on a 40mm lens with a slow forward dolly. He runs toward the camera along the slushy lane edge, carefully placing feet to avoid slick patches; his breath puffs rhythmically and streetlight reflections ripple with each footfall." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Hard cut to an indoor gym with bright, even lighting and clean, family-friendly atmosphere. Same athlete (exact identity and outfit unchanged) is on a treadmill facing left, hands lightly on side rails as he steps on. Background: free weights neatly racked, a water dispenser, rubber flooring, large windows showing neutral daylight (no logos).", + "video_prompt": "Three-quarter angle medium-wide shot on a 24mm lens from the treadmill’s front-right corner. He starts the treadmill, begins at a brisk walk into a jog, posture tall; the belt moves beneath him while overhead lights create soft reflections on the treadmill frame." + }, + { + "shot_id": 10, + "first_frame": "Indoor gym close-up of the athlete’s face and upper chest. Scar on left eyebrow is crisp; short curly hair and trimmed beard consistent. Forest-green jacket zipper and reflective piping visible. He focuses forward, beads of sweat beginning at the hairline. Background softly blurred: treadmill console lights (generic), neutral walls.", + "video_prompt": "Eye-level close-up on an 85mm lens, locked-off camera. He inhales through the nose, exhales steadily, then subtly tightens jaw with determination; sweat glistens under the gym lights and the reflective piping catches brief highlights as his shoulders move." + }, + { + "shot_id": 11, + "first_frame": "Hard cut to a foggy forest trail at dawn. Same athlete in same outfit runs away from camera along a dirt path with wet leaves. Tall trees fade into mist; soft, diffuse light filters through branches. The lime-green shoe stripe is visible as his feet lift. Mood is quiet, focused.", + "video_prompt": "Rear tracking shot at knee height on a 35mm lens, camera following a few meters behind. He accelerates into a tempo run, footfalls muffled by damp earth; fog swirls as he passes, and droplets shake from low branches when his shoulder brushes near them." + }, + { + "shot_id": 12, + "first_frame": "Hard cut to a steep stone staircase in an old hillside neighborhood during late afternoon. Same athlete, same clothing and features, climbs upward. Textured stone steps, pastel walls, potted plants along edges. Warm sunlight casts diagonal shadows; the setting is dry and bright, sharply different from fog.", + "video_prompt": "Low angle wide shot on a 18mm lens from the base of the steps. He runs stair intervals—driving knees up, hands relaxed—ascending rapidly for several steps; the camera remains fixed as he passes upward through the frame, sunlight flashing across the reflective piping with each stride." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Hard cut to a high-altitude rocky ridgeline at sunrise, dramatic and clear. Same athlete, unchanged outfit and identity, stands near a trail marker (generic) with a vast valley below. He checks his black sports watch; the left-eyebrow scar visible in profile. Cold golden light illuminates rocks and the green jacket, wind lightly tugging fabric.", + "video_prompt": "Eye-level medium-wide shot on a 28mm lens with a slow push-in. He taps the watch, sets his stance, and takes a deep breath; wind gusts ripple the jacket and a few small pebbles skitter near his shoes as the sun crests higher." + }, + { + "shot_id": 14, + "first_frame": "Hard cut to a narrow mountain trail with patches of snow and exposed rock. Same athlete runs toward camera, carefully navigating uneven terrain. The white shoes with lime stripe contrast against dark stone; charcoal pants show a bit of dust at the cuffs. Bright, crisp alpine light; distant peaks in background.", + "video_prompt": "Low angle medium shot on a 35mm lens, camera positioned just off the trail edge. He performs controlled, quick steps over rocks, briefly hopping across a small snowy patch; his arms extend slightly for balance, and loose snow crystals scatter as his shoe lands." + }, + { + "shot_id": 15, + "first_frame": "Hard cut to a dramatic side profile on the same mountain: the athlete climbs a final steep switchback as clouds roll in, shifting light from warm to cool. Same outfit and facial features. Background shows a sharp drop-off with layered mountains; wind stronger, jacket flutters. Mood: intense but safe, no perilous actions beyond steady running.", + "video_prompt": "Side-on telephoto shot on a 100mm lens with slight camera pan following him uphill. He digs in for the final push, breathing visible in the colder air; clouds slide over the sun causing the scene to dim, then brighten briefly, emphasizing effort and changing conditions." + }, + { + "shot_id": 16, + "first_frame": "Hard cut to the mountain summit plateau under clear sky. Same athlete, same forest-green jacket, charcoal pants, white shoes with lime stripe, black watch, scar on left eyebrow. He slows to a stop at the top, hands on hips, smiling calmly. Background: panoramic view, sunlit rocks, a small cairn, crisp air. Lighting: bright morning sun with clean shadows; triumphant, family-friendly tone.", + "video_prompt": "Eye-level wide shot on a 24mm lens, static camera. He walks a few steps forward, looks across the view, then raises one fist briefly in quiet celebration before relaxing; wind gently moves his jacket while sunlight sharpens the reflective piping, concluding the training arc at the summit." + } + ] + } + ], + "metadata": { + "theme_key": "athlete_training_conditions", + "theme_description": "An athlete training in different weather and terrain conditions", + "consistency_type": "Type A", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/athletes_gym_training_duo_typeC.json b/vimax_benchmark/athletes_gym_training_duo_typeC.json new file mode 100644 index 0000000..fe97cde --- /dev/null +++ b/vimax_benchmark/athletes_gym_training_duo_typeC.json @@ -0,0 +1,68 @@ +{ + "story_overview": "Two adult athletes train together in a modern gym, warming up, coaching each other through technique drills, and culminating in a timed partner challenge that ends with a respectful handshake and shared satisfaction.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a clean, modern indoor gym with rubber flooring, squat racks, mirrors, and neatly racked dumbbells. Two adult athletes (both 20+): Athlete A is a tall, athletic woman (late 20s) with medium-brown skin, dark curly hair in a high ponytail, wearing a forest-green zip-up track jacket, black full-length training leggings, and white trainers. Athlete B is a shorter, athletic man (early 30s) with light skin, short dark hair, neatly trimmed beard, wearing a navy long-sleeve performance top, charcoal training pants, and black trainers. Both stand near a stretching mat and a water bottle, facing each other with relaxed, focused expressions under bright, even overhead lighting.", + "video_prompt": "Eye-level wide shot, 24mm lens, static tripod. Athlete A and Athlete B perform a synchronized warm-up: shoulder rolls and gentle lunges in place while exchanging a few encouraging gestures. Athlete B points toward the lifting platform as Athlete A nods; both step forward together, staying side-by-side. Reflections in the mirror remain stable; no text legible in the environment." + }, + { + "shot_id": 2, + "first_frame": "Medium two-shot from a slight angle near the mirror: Athlete A and Athlete B kneel on adjacent mats. Athlete A’s forest-green jacket and black leggings are clearly visible; Athlete B’s navy top and charcoal pants remain distinct. A foam roller sits between them. Soft bounce light from overhead panels creates gentle highlights on the rubber floor texture.", + "video_prompt": "Eye-level medium two-shot, 35mm lens, slow lateral slider move left-to-right. Athlete A demonstrates rolling her calf on the foam roller; Athlete B watches, then mirrors the motion on his own leg without swapping clothing or features. Athlete A gives a small thumbs-up; Athlete B nods, focused, maintaining clear separation of identities." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder shot from behind Athlete B (navy top visible in foreground shoulder blur), looking toward Athlete A standing at a squat rack. Athlete A grips a light barbell on the rack, feet shoulder-width on a lifting platform. The gym background shows neatly stacked plates and a mirror, all clean and PG-rated.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens, subtle handheld steadiness. Athlete A practices controlled air squats with the empty bar on her back (no strain, safe form). Athlete B, in the foreground, raises a hand to cue depth and posture. Athlete A adjusts her stance slightly, keeping her forest-green jacket zipped and modest." + }, + { + "shot_id": 4, + "first_frame": "Reverse over-the-shoulder shot from behind Athlete A (forest-green jacket shoulder in foreground), looking at Athlete B beside a flat bench holding a pair of light dumbbells. Athlete B sits upright with a neutral spine; Athlete A stands to the side as a spotter/coach. Bright overhead lighting with crisp shadows under the bench.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens, locked-off. Athlete B performs slow, controlled seated dumbbell presses. Athlete A counts reps with fingers and demonstrates a small wrist-alignment cue in the air. Athlete B adjusts his elbow path and completes the set, then places dumbbells down carefully—no impact or unsafe motion." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Low-angle wide shot of an agility lane marked by cones on the rubber floor. Athlete A (green jacket, black leggings) and Athlete B (navy top, charcoal pants) stand at the start line, side-by-side, facing forward. A jump rope and a small plyo box sit off to the right. The mood is energized but friendly.", + "video_prompt": "Low-angle wide shot, 28mm lens, slight push-in on a dolly. On a silent count-in, both athletes begin a coordinated agility drill: quick-feet steps through the cone lane. Athlete A leads by half a step; Athlete B matches pace, staying in his lane. Their footwork is crisp; cones remain stationary and un-kicked." + }, + { + "shot_id": 6, + "first_frame": "High-angle shot from above a medicine-ball area: Athlete A and Athlete B face each other about two meters apart. Athlete A holds a medium medicine ball at chest height; Athlete B has hands ready to receive. Their outfits and facial features remain clearly distinct. Background shows organized racks and a mirrored wall.", + "video_prompt": "Overhead high-angle medium-wide shot, 24mm lens, static. Athlete A chest-passes the medicine ball to Athlete B; he catches it cleanly, steps back, then returns a controlled pass. They repeat several passes with steady rhythm. The ball’s path is smooth and centered; no collisions or chaotic motion." + }, + { + "shot_id": 7, + "first_frame": "Eye-level tight two-shot near a wall-mounted timer (numbers not legible). Athlete A and Athlete B stand close, slightly angled toward each other, breathing calmly and focused. Athlete A holds a small towel; Athlete B holds a water bottle. Lighting is bright, neutral, and flattering.", + "video_prompt": "Eye-level close two-shot, 65mm lens, gentle rack focus between faces. Athlete B points toward the timer and raises two fingers to indicate a two-part finisher; Athlete A nods and taps her towel against her palm, determined. They bump fists lightly (friendly, PG) and step out of frame together, maintaining distinct clothing and identities." + }, + { + "shot_id": 8, + "first_frame": "Dynamic side-on medium-wide shot of a sled track lane: a small weighted push sled sits on the rubber surface. Athlete A is positioned behind the sled handles, knees bent and back straight; Athlete B stands just behind and to the left, ready to coach and pace. Their outfits remain unchanged and modest.", + "video_prompt": "Side-on medium-wide shot, 35mm lens, tracking right-to-left parallel to the lane. Athlete A drives the sled forward with steady steps. Athlete B jogs alongside, calling cadence with hand signals and keeping a safe distance. The sled glides smoothly; Athlete A reaches the lane marker at the end of the clip, showing effort without distress." + }, + { + "shot_id": 9, + "first_frame": "Wide finishing shot near the gym’s main floor: Athlete A and Athlete B stand facing each other in the center of frame, posture relaxed. Athlete A’s forest-green jacket and Athlete B’s navy top are crisp and unchanged. A clean, organized gym background with mirrors and racks; warm, slightly softer lighting suggests a satisfying wrap-up.", + "video_prompt": "Eye-level wide shot, 24mm lens, slow pull-back. Athlete B offers a handshake; Athlete A accepts, then they both smile with quiet pride. They pick up their water bottle and towel (each holding their own items) and walk side-by-side toward the exit area, ending the training session on a friendly, accomplished climax." + } + ] + } + ], + "metadata": { + "theme_key": "athletes_gym_training_duo", + "theme_description": "Two athletes training together at a gym", + "consistency_type": "Type C", + "requested_scenes": 2, + "requested_shots": 9 + } +} diff --git a/vimax_benchmark/barista_coffee_cultures_typeA.json b/vimax_benchmark/barista_coffee_cultures_typeA.json new file mode 100644 index 0000000..c839c15 --- /dev/null +++ b/vimax_benchmark/barista_coffee_cultures_typeA.json @@ -0,0 +1,63 @@ +{ + "story_overview": "A single barista travels through dramatically different coffee cultures in quick vignettes, learning distinct preparation methods before culminating in a warm, inclusive community tasting where the barista blends techniques into one signature cup.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Interior, modern coffee shop at sunrise. A 28-year-old adult woman barista with warm brown skin, almond-shaped dark eyes, and a small star-shaped freckle just under her left eye stands behind a light-oak counter. She has short, tightly-curled dark hair tucked under a matte charcoal beanie. She wears a forest-green button-up shirt, a tan canvas apron with a stitched name tag reading \"MARA,\" dark straight-leg trousers, and black lace-up shoes. On the counter: a clean espresso machine, a scale, a metal pitcher, and a ceramic cup. Soft golden window light, calm mood, crisp textures on wood and brushed steel.", + "video_prompt": "Eye-level medium-wide shot with a 35mm lens from behind the counter. Mara wipes the counter, checks the scale, and gently places a ceramic cup center-frame; she pauses, takes a focused breath, and looks toward the shop window as the morning light brightens slightly, signaling the start of her learning journey." + }, + { + "shot_id": 2, + "first_frame": "High-contrast, close-up countertop view in a compact urban cafe. The same Mara (star freckle under left eye, charcoal beanie, forest-green shirt, tan apron labeled \"MARA\") is seen from chest to hands. A V60-style dripper sits atop a glass server; a gooseneck kettle steams nearby; a timer and scale are aligned neatly. Cool daylight from a side window; textures: wet paper filter, glossy glass, matte plastic timer.", + "video_prompt": "Top-down close-up with a 50mm lens. Mara rinses the paper filter, then pours in a controlled spiral from the gooseneck kettle; steam rises and the coffee bed blooms, expanding and settling as she adjusts her wrist angle precisely while the timer ticks." + }, + { + "shot_id": 3, + "first_frame": "Outdoor open-air market stall under bright midday sun. The same Mara (charcoal beanie, forest-green shirt, tan apron with \"MARA\") stands at a rustic wooden table covered with a patterned cloth. A traditional hand grinder and a small clay coffee pot sit beside a tray of cups. Background: colorful fabric canopy and blurred market movement. Light is harsh and warm; dust motes visible; lively but family-friendly atmosphere.", + "video_prompt": "Low-angle medium shot with a 28mm lens from table height, slightly tilted upward. Mara turns the hand grinder steadily, then lifts the small clay pot carefully, wafting the aroma toward herself; she smiles with concentration as sunlight glints off the grinder handle and the canopy fabric ripples in a breeze." + }, + { + "shot_id": 4, + "first_frame": "Dim, intimate interior with warm tungsten lanterns: a traditional coffee room with a low table and patterned cushions. The same Mara (star freckle under left eye, charcoal beanie, forest-green shirt, tan apron labeled \"MARA\") kneels comfortably beside a small brass pot on a sand-heated tray. A long-handled spoon and a small cup are placed neatly. Mood is serene; textures: gleaming brass, fine sand, embroidered textiles.", + "video_prompt": "Side-profile close shot with an 85mm lens at seated eye level. Mara stirs slowly, watching tiny bubbles form at the rim; she lifts the brass pot briefly, sets it back into the hot sand, and repeats with patience, her face reflecting soft lantern light as the surface foam rises and settles." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Snowy mountain lodge interior with large frosted windows and a stone fireplace glowing softly. The same Mara (charcoal beanie, forest-green shirt, tan apron with \"MARA,\" dark trousers) stands at a sturdy wooden bar. In front: an AeroPress-style brewer, a kettle, and a mug. Cool blue window light mixes with warm firelight; cozy mood; visible condensation on window panes.", + "video_prompt": "Eye-level medium shot with a 35mm lens from the bar’s front edge. Mara assembles the brewer with brisk, practiced movements, pours hot water carefully, and presses smoothly; steam curls upward as she warms her hands near the mug, the mixed lighting shifting across her face and apron as the fire flickers." + }, + { + "shot_id": 6, + "first_frame": "Bright, minimalist kitchen studio with white tile and softbox lighting. The same Mara (star freckle under left eye, charcoal beanie, forest-green shirt, tan apron labeled \"MARA\") stands at a stainless counter. A siphon vacuum brewer (glass globes, metal stand) is centered; a small burner below; a thermometer and spoon nearby. Clean, clinical mood; crisp reflections on glass and steel.", + "video_prompt": "Locked-off wide shot with a 24mm lens, straight-on. Mara ignites the small burner, watches water rise into the upper globe, then stirs gently; as the brew finishes, she removes the heat and observes the brewed coffee draw down, her posture attentive while the glass apparatus shimmers under studio lights." + }, + { + "shot_id": 7, + "first_frame": "Evening city rooftop under string lights with a panoramic skyline in soft bokeh. The same Mara (charcoal beanie, forest-green shirt, tan apron with \"MARA\") stands at a small table set with a moka pot, a milk frother, and three ceramic cups. Wind lightly lifts the apron fabric; warm string-light glow against cool night tones; friendly, anticipatory mood.", + "video_prompt": "Over-the-shoulder medium shot with a 50mm lens from behind Mara’s right shoulder. She sets the moka pot down, listens for the final sputter, then froths milk with careful, modest movements; she pours a small, neat topping into one cup, glances at the other cups as if planning a shared tasting, and nods with growing confidence." + }, + { + "shot_id": 8, + "first_frame": "Community tasting table in the original modern coffee shop, now in late afternoon with warm, amber sunlight. The same Mara (star freckle under left eye, charcoal beanie, forest-green shirt, tan apron labeled \"MARA\") stands centered behind a long light-oak table. On the table: a neat lineup of brewed samples labeled with small handwritten cards (e.g., \"Pour-over,\" \"Sand-heated pot,\" \"Press,\" \"Siphon\"), a kettle, and a single final cup in the middle. Several adult customers (faces softly out of focus, modest casual clothing) stand at a respectful distance, attentive and smiling. Mood: uplifting, inclusive; textures: paper labels, ceramic glaze, sunlit wood grain.", + "video_prompt": "Slow push-in medium-wide shot with a 35mm lens at eye level from the customer side of the table. Mara combines a careful technique—brief bloom pour, gentle stir, and a final measured pour—then slides the signature cup forward; she invites the group with an open-handed gesture, and the customers lean in slightly to smell the aroma as the sunlight flares softly, marking the climax: her confident, culture-spanning craft." + } + ] + } + ], + "metadata": { + "theme_key": "barista_coffee_cultures", + "theme_description": "A barista experiencing coffee preparation methods across cultures", + "consistency_type": "Type A", + "requested_scenes": 2, + "requested_shots": 8 + } +} diff --git a/vimax_benchmark/benchmark_index.json b/vimax_benchmark/benchmark_index.json new file mode 100644 index 0000000..a8c741f --- /dev/null +++ b/vimax_benchmark/benchmark_index.json @@ -0,0 +1,219 @@ +{ + "total_stories": 35, + "model_config": { + "model": "", + "api": "" + }, + "stories": [ + { + "id": 1, + "type": "Type A", + "theme": "chef_international_kitchens", + "file": "chef_international_kitchens_typeA.json" + }, + { + "id": 2, + "type": "Type A", + "theme": "artist_extreme_weather", + "file": "artist_extreme_weather_typeA.json" + }, + { + "id": 3, + "type": "Type A", + "theme": "musician_cultural_venues", + "file": "musician_cultural_venues_typeA.json" + }, + { + "id": 4, + "type": "Type A", + "theme": "photographer_urban_timelapses", + "file": "photographer_urban_timelapses_typeA.json" + }, + { + "id": 5, + "type": "Type A", + "theme": "scientist_natural_environments", + "file": "scientist_natural_environments_typeA.json" + }, + { + "id": 6, + "type": "Type A", + "theme": "writer_contrasting_locations", + "file": "writer_contrasting_locations_typeA.json" + }, + { + "id": 7, + "type": "Type A", + "theme": "dancer_architectural_spaces", + "file": "dancer_architectural_spaces_typeA.json" + }, + { + "id": 8, + "type": "Type A", + "theme": "gardener_four_seasons", + "file": "gardener_four_seasons_typeA.json" + }, + { + "id": 9, + "type": "Type A", + "theme": "athlete_training_conditions", + "file": "athlete_training_conditions_typeA.json" + }, + { + "id": 10, + "type": "Type A", + "theme": "barista_coffee_cultures", + "file": "barista_coffee_cultures_typeA.json" + }, + { + "id": 11, + "type": "Type A", + "theme": "filmmaker_lighting_setups", + "file": "filmmaker_lighting_setups_typeA.json" + }, + { + "id": 12, + "type": "Type A", + "theme": "fashion_model_runway_styles", + "file": "fashion_model_runway_styles_typeA.json" + }, + { + "id": 13, + "type": "Type B", + "theme": "investigation_antique_bookshop", + "file": "investigation_antique_bookshop_typeB.json" + }, + { + "id": 14, + "type": "Type B", + "theme": "cooking_competition_restaurant", + "file": "cooking_competition_restaurant_typeB.json" + }, + { + "id": 15, + "type": "Type B", + "theme": "puzzle_solving_escape_room", + "file": "puzzle_solving_escape_room_typeB.json" + }, + { + "id": 16, + "type": "Type B", + "theme": "fashion_shoot_art_gallery", + "file": "fashion_shoot_art_gallery_typeB.json" + }, + { + "id": 17, + "type": "Type B", + "theme": "family_gathering_victorian_mansion", + "file": "family_gathering_victorian_mansion_typeB.json" + }, + { + "id": 18, + "type": "Type B", + "theme": "theater_rehearsal_community_stage", + "file": "theater_rehearsal_community_stage_typeB.json" + }, + { + "id": 19, + "type": "Type B", + "theme": "science_demo_university_lab", + "file": "science_demo_university_lab_typeB.json" + }, + { + "id": 20, + "type": "Type B", + "theme": "meditation_class_zen_temple", + "file": "meditation_class_zen_temple_typeB.json" + }, + { + "id": 21, + "type": "Type B", + "theme": "wine_tasting_cellar_venue", + "file": "wine_tasting_cellar_venue_typeB.json" + }, + { + "id": 22, + "type": "Type B", + "theme": "pottery_workshop_studio_space", + "file": "pottery_workshop_studio_space_typeB.json" + }, + { + "id": 23, + "type": "Type B", + "theme": "music_recording_home_studio", + "file": "music_recording_home_studio_typeB.json" + }, + { + "id": 24, + "type": "Type B", + "theme": "board_game_cafe_interior", + "file": "board_game_cafe_interior_typeB.json" + }, + { + "id": 25, + "type": "Type C", + "theme": "business_partners_office_negotiation", + "file": "business_partners_office_negotiation_typeC.json" + }, + { + "id": 26, + "type": "Type C", + "theme": "mentor_student_craft_learning", + "file": "mentor_student_craft_learning_typeC.json" + }, + { + "id": 27, + "type": "Type C", + "theme": "three_friends_surprise_planning", + "file": "three_friends_surprise_planning_typeC.json" + }, + { + "id": 28, + "type": "Type C", + "theme": "couple_cooking_dinner_together", + "file": "couple_cooking_dinner_together_typeC.json" + }, + { + "id": 29, + "type": "Type C", + "theme": "scientists_lab_collaboration", + "file": "scientists_lab_collaboration_typeC.json" + }, + { + "id": 30, + "type": "Type C", + "theme": "teacher_student_tutoring_session", + "file": "teacher_student_tutoring_session_typeC.json" + }, + { + "id": 31, + "type": "Type C", + "theme": "athletes_gym_training_duo", + "file": "athletes_gym_training_duo_typeC.json" + }, + { + "id": 32, + "type": "Type C", + "theme": "detective_witness_interview", + "file": "detective_witness_interview_typeC.json" + }, + { + "id": 33, + "type": "Type C", + "theme": "colleagues_brainstorming_ideas", + "file": "colleagues_brainstorming_ideas_typeC.json" + }, + { + "id": 34, + "type": "Type C", + "theme": "siblings_organizing_family_event", + "file": "siblings_organizing_family_event_typeC.json" + }, + { + "id": 35, + "type": "Type C", + "theme": "musicians_band_rehearsal", + "file": "musicians_band_rehearsal_typeC.json" + } + ] +} diff --git a/vimax_benchmark/board_game_cafe_interior_typeB.json b/vimax_benchmark/board_game_cafe_interior_typeB.json new file mode 100644 index 0000000..2c8233d --- /dev/null +++ b/vimax_benchmark/board_game_cafe_interior_typeB.json @@ -0,0 +1,113 @@ +{ + "story_overview": "In a cozy cafe hosting a friendly board game tournament, three adult participants navigate tense rounds, an unexpected rules question, and a final decisive move—while the cafe’s warm interior remains perfectly consistent across every shot.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a cozy indoor cafe game corner with fixed geometry: a honey-colored wooden floor; exposed brick back wall with three framed art prints aligned horizontally; a tall black metal-and-wood bookshelf on the left wall; a large front window on the right with rain streaks and warm streetlight glow; a long oak communal table centered with four mismatched wooden chairs (two on each long side); a small round table in the back-right with two chairs; a brass floor lamp near the bookshelf; a wooden service counter at the back with a glass pastry case and a menu board above it. On the communal table: a colorful board game set up with cards, tokens, and a score pad; ceramic mugs; a small jar of pencils. Lighting is warm, amber, and soft; everything feels inviting and PG-rated. Three adults (all 20+): a 32-year-old woman with medium-brown skin, curly dark hair in a low bun, wearing a teal knit sweater and dark jeans; a 35-year-old man with light skin, short sandy hair, wearing a charcoal cardigan over a collared shirt; a 29-year-old East Asian man with short black hair, wearing a forest-green hoodie and khaki chinos. They sit/stand around the communal table preparing to start.", + "video_prompt": "Eye-level wide shot from the cafe entrance corner, 24mm lens. The camera holds steady with a slight slow push-in as the three adults arrange components: the woman slides a deck of cards to the center, the man in the cardigan places tokens into neat piles, and the man in the hoodie sets a mug down carefully. Background remains unchanged: bookshelf left, brick wall with frames, counter and pastry case at back, rainy window right." + }, + { + "shot_id": 2, + "first_frame": "Medium shot focused on the communal table from the bookshelf side, keeping the same cafe layout visible behind: brick wall and framed prints centered; brass floor lamp partially in frame left; counter and menu board in the distance. The teal-sweater woman sits on the left side chair nearest camera, hands hovering over the rulebook; the cardigan man sits opposite her, pen poised above the score pad; the green-hoodie man sits to the right, organizing tokens. Mugs and pencils jar are in the foreground; board and cards are crisp and colorful.", + "video_prompt": "Shoulder-height medium shot, 35mm lens, static tripod. Over 6 seconds, the teal-sweater woman opens the rulebook and points to a section; the cardigan man nods and writes the tournament round number on the score pad; the hoodie man deals starting cards in a smooth clockwise motion. Subtle cafe ambience: warm reflections on the wooden table; rain glow from the right window remains consistent." + }, + { + "shot_id": 3, + "first_frame": "Low angle close-up from table level looking across the board game toward the counter in the background (counter, pastry case, and menu board stay fixed). The board fills the foreground with tokens and a small hourglass timer. The cardigan man’s hands enter from left holding a card; the hoodie man’s hand from right reaches toward a token; the teal-sweater woman’s hands rest near the rulebook at the top edge of frame.", + "video_prompt": "Low angle close-up, 50mm lens, shallow depth of field. Over 5–7 seconds, the hourglass is flipped (sand starts flowing), the cardigan man places a card onto the board, and the hoodie man slides a token precisely into a new space. The camera remains locked; only hands and small objects move, emphasizing contact and physics on the tabletop." + }, + { + "shot_id": 4, + "first_frame": "Overhead shot directly above the communal table showing the full board layout and all three players’ positions around it. The cafe’s fixed elements are visible at the edges: the right-side rainy window glow, the left bookshelf edge, and the back counter line. The teal-sweater woman leans forward slightly; the cardigan man has his pen and score pad at the top; the hoodie man has a small hand of cards at the right. Tokens are neatly arranged; mugs sit near each player.", + "video_prompt": "Overhead top-down shot, 28mm equivalent, static. Over 6 seconds, the teal-sweater woman draws one card and places it face-up; the cardigan man updates the score pad with two quick strokes; the hoodie man taps the hourglass to signal time is nearly up. Movements remain contained to the table; the cafe background geometry stays perfectly stable." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Wide shot from near the front window looking inward across the room: rainy window frame dominates right foreground; communal table is mid-ground; bookshelf and brass lamp on left; brick wall and framed prints centered; counter and pastry case at back. The hoodie man stands and steps away from his chair, moving toward the counter; the teal-sweater woman remains seated; the cardigan man half-turns in his chair. Chairs, tables, and furniture placement are unchanged.", + "video_prompt": "Eye-level wide shot, 24mm lens. Over 7 seconds, the hoodie man walks from the communal table toward the back counter, passing behind the near-side chair and briefly occluding his torso with chair backs as he threads through. The camera stays fixed; depth is emphasized by his movement from mid-ground to background." + }, + { + "shot_id": 6, + "first_frame": "Medium shot aimed at the back counter area (same counter, glass pastry case, and menu board). The hoodie man arrives at the counter and leans his forearms lightly on the wooden edge; a barista (adult, 26-year-old woman with light-brown skin, hair in a neat ponytail, wearing a modest black t-shirt and a tan apron) stands behind the pastry case. The communal table is visible in the far left background, unchanged.", + "video_prompt": "Eye-level medium shot, 35mm lens. Over 6 seconds, the hoodie man points politely at the menu board and speaks; the barista nods, reaches down to pick up a ceramic mug, and sets it on the counter with a gentle clink. The hoodie man slides a small receipt-sized slip toward himself. All actions remain friendly and non-violent." + }, + { + "shot_id": 7, + "first_frame": "Tracking-ready composition along the left side near the bookshelf: the black metal-and-wood bookshelf dominates left; brass floor lamp stands in front of it; communal table is mid-ground. The hoodie man is returning from the counter carrying a mug with both hands, walking behind the brass lamp and partially occluded by it for a moment. The teal-sweater woman watches from her seat; the cardigan man tidies cards.", + "video_prompt": "Side-on medium-wide shot, 28mm lens, gentle lateral tracking (camera moves slightly right). Over 5–8 seconds, the hoodie man walks from background toward mid-ground, passing behind the brass lamp (occlusion test), then re-emerges and slows near his chair. The camera’s small glide accentuates the room’s fixed depth and the solidity of the lamp and bookshelf." + }, + { + "shot_id": 8, + "first_frame": "Close shot focused on seating interaction at the communal table from the brick-wall side: the teal-sweater woman sits left, cardigan man sits opposite, hoodie man stands beside his chair on the right holding the mug. The chair legs and table legs are clearly visible on the wooden floor; tokens and cards remain in consistent places relative to the board. Warm light reflects off the tabletop.", + "video_prompt": "Eye-level close shot, 50mm lens. Over 6 seconds, the hoodie man pulls his chair back, sits down fully (clear contact with chair seat), and places the mug on the table without spilling. The teal-sweater woman slides the rulebook toward him, and the cardigan man rotates the score pad slightly to face the group. Camera remains steady; background geometry does not change." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Over-the-shoulder shot from behind the cardigan man’s right shoulder toward the teal-sweater woman and the board. The brick wall with framed prints is sharp behind her; the rainy window glow softly rims the table edge. The teal-sweater woman holds the rulebook open; a disputed card lies near the center of the board; the hoodie man’s hands rest near his tokens.", + "video_prompt": "Over-the-shoulder medium shot, 45mm lens. Over 6 seconds, the teal-sweater woman calmly points to a specific line in the rulebook and traces it with her finger; the cardigan man’s shoulder subtly shifts as he leans in; the hoodie man nods and repositions one token to match the clarified rule. No argument—just a friendly clarification." + }, + { + "shot_id": 10, + "first_frame": "High angle medium-wide shot from near the brass floor lamp looking down toward the communal table. The lamp, bookshelf, and table edges form strong diagonals; the counter and pastry case remain in the back. The cardigan man reaches across the table with the hourglass; the teal-sweater woman sits upright; the hoodie man holds a card close to his chest, thinking.", + "video_prompt": "High angle medium-wide shot, 30mm lens, static. Over 7 seconds, the cardigan man passes the hourglass across the table to the teal-sweater woman (object handoff), who receives it and sets it beside the board; the hoodie man places a card onto the table and then slides his hand back. The movement tests contact and placement on the tabletop." + }, + { + "shot_id": 11, + "first_frame": "Low angle medium shot from beneath table height but not showing anything inappropriate—just legs, chair rungs, and the lower edges of the table. The wooden floorboards and chair legs are crisp; the hoodie man’s feet step carefully as he scoots his chair inward; the teal-sweater woman’s shoes are planted; the cardigan man’s chair shifts slightly. The back counter line is visible above the table edge, unchanged.", + "video_prompt": "Low angle medium shot, 28mm lens. Over 5–7 seconds, the hoodie man pushes his chair in (chair legs slide a few centimeters, audible scrape implied), then stills; the cardigan man adjusts his chair angle slightly; the teal-sweater woman taps a foot once, then stops. Camera remains fixed, highlighting realistic floor contact and stable room geometry." + }, + { + "shot_id": 12, + "first_frame": "Tight close-up on the score pad and pencil jar at the table’s edge, with the board blurred behind. The cardigan man’s hand holds a pencil above the score pad; the teal-sweater woman’s hand enters to point at a number; the hoodie man’s hand places a token near the pad. The warm cafe light creates soft shadows; the rainy window glow is a cool accent.", + "video_prompt": "Tabletop close-up, 65mm lens, shallow depth of field. Over 6 seconds, the cardigan man writes updated scores in two lines, the teal-sweater woman gently taps the correct column, and the hoodie man nudges a token into a neat stack beside the pad. The camera stays locked; the action is precise and tactile." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Wide dramatic composition from the back counter area looking toward the front window: counter and pastry case in the foreground bottom edge; communal table is center; rainy window glows right; bookshelf left. The three adults lean in toward the board in anticipation. The hourglass is centered and ready; mugs sit near each player; chairs remain in the same positions.", + "video_prompt": "Eye-level wide shot, 24mm lens, slow push-in. Over 7 seconds, the teal-sweater woman flips the hourglass for the final round; the cardigan man straightens the score pad; the hoodie man fans his cards once and selects one, holding it poised above the board. The room stays perfectly consistent; tension is conveyed through body language only." + }, + { + "shot_id": 14, + "first_frame": "Extreme close-up of the hoodie man’s chosen card hovering over the board space, with the hourglass and a cluster of tokens visible. The card art is generic and non-branded. The hoodie man’s fingers are steady; the teal-sweater woman’s hands are clasped; the cardigan man’s pencil tip waits just out of focus.", + "video_prompt": "Extreme close-up, 85mm lens, shallow depth. Over 5–6 seconds, the hoodie man lowers the card onto the board with a deliberate motion, releases it, then slides a token to complete the action. The hourglass sand continues to fall. The shot emphasizes precise placement and contact with the board." + }, + { + "shot_id": 15, + "first_frame": "Reaction two-shot from the window side: the teal-sweater woman and cardigan man are in the foreground facing the board (off-camera left). The rainy window frames them with soft backlight; brick wall and frames are still visible deeper in the room. The teal-sweater woman’s eyebrows lift in surprise; the cardigan man smiles and glances down at the score pad.", + "video_prompt": "Eye-level medium two-shot, 40mm lens. Over 6 seconds, the teal-sweater woman exhales and smiles, then nods appreciatively; the cardigan man writes the final score and turns the pad slightly toward the center of the table. Their movement is small and natural; background remains unchanged." + }, + { + "shot_id": 16, + "first_frame": "Overhead final tableau above the communal table: the completed board state is clearly visible; the score pad shows final tallies (no readable brand names). The three adults’ hands meet briefly in the center for a friendly group handshake-like clasp (palms and fingers only), then rest back near their mugs. The cafe edges—bookshelf left, window right, counter back—remain stable and consistent.", + "video_prompt": "Overhead top-down shot, 28mm equivalent, static. Over 6–8 seconds, the group performs a brief friendly congratulations gesture: hands come together at the center, then separate; the teal-sweater woman closes the rulebook; the hoodie man gathers cards into a neat stack; the cardigan man places the pencil back into the jar. The scene ends with an orderly, warm, family-friendly finish in the same unchanging cafe environment." + } + ] + } + ], + "metadata": { + "theme_key": "board_game_cafe_interior", + "theme_description": "A board game tournament in a cozy cafe", + "consistency_type": "Type B", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/business_partners_office_negotiation_typeC.json b/vimax_benchmark/business_partners_office_negotiation_typeC.json new file mode 100644 index 0000000..d5cb4c7 --- /dev/null +++ b/vimax_benchmark/business_partners_office_negotiation_typeC.json @@ -0,0 +1,68 @@ +{ + "story_overview": "In a modern office, two adult business partners negotiate a vendor deal: they start with differing terms, work through a product demo and budget tradeoffs, and culminate in a signed agreement and handshake.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing image of a modern, tidy office conference room in daylight. A rectangular light-oak table sits center with two gray upholstered chairs facing each other. On the left sits Character A: a 32-year-old woman, tall and athletic build, medium-brown skin, neat black hair in a low bun, wearing a teal blazer over a white button-up shirt, charcoal slacks, and black loafers; thin silver rectangular eyeglasses. On the right sits Character B: a 38-year-old man, shorter and stockier build, light skin, neatly trimmed dark beard, short dark hair, wearing a navy suit, light-blue button-up shirt (no tie), and brown leather shoes. Between them: a closed silver laptop, a notepad with a pen, and a small cup of pens. Background: glass wall with subtle frosting, a potted plant in the corner, and a whiteboard with simple, non-branded charts. Mood: professional, calm.", + "video_prompt": "Eye-level wide shot, 24mm lens. Over 6 seconds, both partners lean slightly forward toward the table; Character A slides the notepad to the center with an open palm, and Character B nods while placing his hands neatly on the table edge, signaling the start of negotiation. Soft daylight remains steady, no camera movement." + }, + { + "shot_id": 2, + "first_frame": "Over-the-shoulder close shot from behind Character B’s right shoulder, focusing on Character A across the table. Character A’s teal blazer and silver rectangular eyeglasses are crisp; her expression is attentive and composed. The notepad is in front of her, pen aligned parallel to the table edge. Character B’s navy suit shoulder is blurred in the foreground. Background shows the frosted glass wall and a corner of the whiteboard.", + "video_prompt": "Over-the-shoulder medium close-up, 50mm lens. Over 5–7 seconds, Character A speaks with measured hand gestures: she taps the notepad once, then points gently toward the closed laptop, suggesting a structured proposal. Character B’s shoulder remains still in foreground; shallow depth of field keeps attention on Character A." + }, + { + "shot_id": 3, + "first_frame": "Reverse over-the-shoulder shot from behind Character A’s left shoulder, focusing on Character B. Character B’s neatly trimmed dark beard and navy suit are clear; he looks thoughtful. Character A’s teal blazer shoulder is blurred foreground. The closed laptop sits between them, slightly nearer to Character B. The potted plant is visible behind Character B.", + "video_prompt": "Over-the-shoulder medium close-up, 50mm lens. Over 6 seconds, Character B responds: he raises one hand with fingers together to indicate a counterpoint, then gently slides the laptop a few inches toward the center without opening it, signaling a request to review details. His expression remains professional and calm." + }, + { + "shot_id": 4, + "first_frame": "Top-down overhead shot of the tabletop. Two sets of hands are visible: Character A’s hands (no rings, neatly manicured) on the left, Character B’s hands on the right. The silver laptop is now centered; the notepad lies slightly left; a printed one-page term sheet (generic, no logos) lies slightly right. Lighting is even daylight, emphasizing clean textures of paper and wood grain.", + "video_prompt": "Overhead static shot, 35mm equivalent. Over 5–8 seconds, Character A pulls the term sheet toward the center, aligning it square with the table edge, while Character B places a finger near a specific line item. They both pause with hands hovering briefly, creating a clear moment of focus on a sticking point." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Side two-shot at table height, medium framing. Character A (teal blazer, white shirt, silver rectangular glasses, hair in low bun) is on the left, Character B (navy suit, light-blue shirt, dark beard) on the right. The laptop is now open and angled slightly toward both. On the screen: a generic bar chart and timeline (no brands). The whiteboard behind them shows simple bullet points and arrows. Mood shifts to more analytical but still friendly.", + "video_prompt": "Eye-level medium two-shot, 35mm lens. Over 6–7 seconds, Character B rotates the open laptop slightly toward Character A, and Character A leans in to examine the chart; she nods once, then points to the timeline on-screen with an open hand (not touching the screen), indicating a compromise path." + }, + { + "shot_id": 6, + "first_frame": "Low-angle close-up of the table edge and hands, looking slightly upward so both torsos are partially visible. Character A’s teal blazer sleeve and Character B’s navy suit sleeve frame the term sheet. A simple calculator (generic) is placed near Character B; the notepad is near Character A. Their hands hover above the paper as if about to mark changes.", + "video_prompt": "Low-angle close-up, 60mm lens. Over 5–8 seconds, Character A writes a brief note on the notepad, then slides the notepad toward Character B. Character B glances down, then taps the calculator once and nods, signaling agreement on revised numbers. Camera remains fixed; the action is hands-driven." + }, + { + "shot_id": 7, + "first_frame": "Over-the-shoulder shot from behind Character B, aimed at Character A as she presents a final point. Character A’s face is sharp; her silver rectangular glasses catch soft window light. Character B’s navy suit shoulder is in foreground blur. The term sheet is now neatly centered, and a pen lies directly above it.", + "video_prompt": "Over-the-shoulder medium close-up, 50mm lens. Over 6 seconds, Character A calmly summarizes: she places her palm flat beside the term sheet (non-threatening, reassuring), then slides the pen toward Character B, inviting signature. Character B’s shoulder shifts slightly, indicating he’s leaning in." + }, + { + "shot_id": 8, + "first_frame": "Tight close-up on Character B’s hands signing the generic term sheet on the table; the pen is in his right hand. Character A’s teal blazer sleeve is visible at frame left, her hands folded politely. The paper texture and ink line are crisp; no personal data or logos are visible.", + "video_prompt": "Macro close-up, 85mm lens. Over 5–7 seconds, Character B signs in one smooth motion, then sets the pen down parallel to the paper edge. Character A’s folded hands relax slightly, and she gives a small approving nod just out of focus, emphasizing the climax: the deal is sealed." + }, + { + "shot_id": 9, + "first_frame": "Medium two-shot standing beside the conference table. Character A (tall, teal blazer, white shirt, charcoal slacks, black loafers, silver rectangular glasses, hair in low bun) stands on the left; Character B (shorter, stockier, navy suit, light-blue shirt, brown shoes, neatly trimmed dark beard) stands on the right. The signed term sheet lies on the table between them; the open laptop is off to the side. Daylight glows through the glass wall; the office looks orderly and warm.", + "video_prompt": "Eye-level medium two-shot, 35mm lens. Over 6 seconds, they complete a professional handshake once (single shake), release, and share a brief satisfied smile. Character A gathers the term sheet into a folder while Character B closes the laptop, concluding the negotiation with a clear, friendly resolution." + } + ] + } + ], + "metadata": { + "theme_key": "business_partners_office_negotiation", + "theme_description": "Two business partners negotiating a deal in an office", + "consistency_type": "Type C", + "requested_scenes": 2, + "requested_shots": 9 + } +} diff --git a/vimax_benchmark/chef_international_kitchens_typeA.json b/vimax_benchmark/chef_international_kitchens_typeA.json new file mode 100644 index 0000000..5bc0a48 --- /dev/null +++ b/vimax_benchmark/chef_international_kitchens_typeA.json @@ -0,0 +1,63 @@ +{ + "story_overview": "An adult chef uses a well-worn recipe notebook to explore four international kitchens in wildly different environments, gathering techniques and flavors, then returns to a modern test kitchen for a final, harmonious dish.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Eye-level medium shot of a 32-year-old chef (adult) with warm brown skin, dark brown eyes, thick eyebrows, and a neat short black undercut; a distinctive crescent-shaped scar above his right eyebrow. He wears a crisp white double-breasted chef jacket with black piping, black-and-white houndstooth pants, a charcoal-gray neckerchief, and a light gray apron with a small embroidered whisk icon at the chest; no jewelry, no hat. He stands in a bright, stainless-steel culinary test kitchen: brushed metal counters, hanging pans, induction burners, a glass-front spice cabinet, and a large window with soft morning light. He holds a worn tan leather recipe notebook with an elastic strap and a clipped-on fountain pen. Mood: focused, curious; clean, PG-rated, modest attire.", + "video_prompt": "Eye-level medium shot, 35mm lens, static camera. The chef opens the tan leather recipe notebook and flips to a page with small hand-drawn ingredient icons; he taps the page with the pen, then looks up toward the prep station. Subtle motion in the background: a kettle releases a gentle puff of steam, and daylight glints off stainless steel. He takes one measured breath, signaling the start of his culinary journey." + }, + { + "shot_id": 2, + "first_frame": "High-angle wide shot looking down into a compact Japanese ramen-ya kitchen with warm lantern lighting and wood textures: a narrow counter, steaming broth pots, hanging ladles, and a noren curtain at the entrance. The same chef (same scar, same face, same exact outfit and apron with whisk icon) stands at a wooden prep board. A small bowl of noodles, sliced scallions, and a simmering pot are arranged neatly. Ambient steam curls upward, creating soft haze.", + "video_prompt": "High-angle wide shot, 24mm lens, gentle right-to-left pan. The chef ladles broth into a bowl, then uses chopsticks to arrange noodles with careful precision. Steam rises and briefly veils his face before clearing, testing identity consistency. He jots a quick note in the tan leather notebook with the clipped pen, then closes it with the elastic strap." + }, + { + "shot_id": 3, + "first_frame": "Low-angle medium-wide shot inside a rustic Italian trattoria kitchen: terracotta tiles, a brick wood-fired oven glowing orange, copper pots on a rack, and a marble-topped table dusted with flour. The same chef (unchanged identity and clothing) leans over the marble table with a rolling pin and a neat mound of dough. Warm, firelit highlights contrast with cooler ambient kitchen light.", + "video_prompt": "Low-angle medium-wide shot, 28mm lens, slight push-in. The chef rolls dough smoothly, then slides it onto a wooden peel near the open oven. The oven flame flickers and casts moving light across his jacket piping and scar. He checks the notebook, nods, and marks a line with the pen while flour motes drift through the air." + }, + { + "shot_id": 4, + "first_frame": "Eye-level close-up of the chef’s hands and upper torso at an open-air Mexican market kitchen stall under bright midday sun: colorful papel picado banners overhead, a comal on a gas burner, bowls of chopped cilantro and onions, limes, and a molcajete (stone mortar). The chef’s face is partially visible: same scar above right eyebrow, same neat undercut, same white jacket with black piping, gray neckerchief, gray apron with whisk icon. Vibrant, high-contrast lighting.", + "video_prompt": "Eye-level close-up, 50mm lens, handheld micro-shake. The chef presses masa into a tortilla, places it on the comal, and uses a spatula to flip it once as it puffs slightly. He squeezes a lime wedge over a small bowl (no mess, no splatter), then quickly scribbles a note in the tan notebook held in his left hand. Sunlight flashes across the apron embroidery as a banner above flutters." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Wide establishing shot of an Indian tandoor kitchen at dusk: clay tandoor ovens, stacks of metal trays, sacks of spices, and warm amber hanging bulbs. The same chef (identical facial features, scar, and outfit) stands near a tandoor opening holding metal tongs and the tan leather notebook tucked under his arm. Heat shimmer is visible above the tandoor, adding atmospheric distortion.", + "video_prompt": "Wide shot, 24mm lens, slow dolly-in. The chef carefully places a flatbread onto the inside wall of the tandoor with practiced motion, then withdraws the tongs. Heat shimmer ripples the air in front of his jacket and face without changing his identity. He briefly opens the notebook, compares a page, and closes it with a decisive nod as the oven glow brightens." + }, + { + "shot_id": 6, + "first_frame": "Overhead medium shot in a modern Scandinavian-inspired kitchen with cool, icy-blue lighting and minimalist design: pale wood counters, matte-white cabinets, a single pendant light, and a frosted window suggesting snowy weather outside. The same chef (unchanged identity and clothing) arranges small bowls: herbs, citrus zest, and a light broth. The tan leather notebook lies open beside a sleek cutting board.", + "video_prompt": "Overhead medium shot, 35mm lens, smooth top-down slide to the right. The chef methodically plates components with tweezers, then pauses to adjust one garnish for symmetry. He turns a notebook page, underlines a note with the pen, and closes the book. The cool lighting shifts subtly as the pendant sways, testing the character’s consistent look under different color temperature." + }, + { + "shot_id": 7, + "first_frame": "Dramatic close-up, three-quarter profile of the chef at a bustling French bistro kitchen pass: warm tungsten lights, order tickets clipped on a rail, polished stainless pass shelf, and a shallow depth of field with soft bokeh of moving staff shapes (no distinct faces). The same chef’s scar above the right eyebrow is clearly visible; same jacket with black piping and gray apron with whisk icon. A plated dish sits in front of him with a small sauce pot nearby. Tension and momentum build.", + "video_prompt": "Close-up, 85mm lens, slight rack focus from the order tickets to the chef’s eyes. He tastes a spoonful of sauce, considers, then adds a careful final swirl to the plate. He snaps the notebook shut with quiet confidence, and the background motion blurs as the pace intensifies—signaling the approaching climax." + }, + { + "shot_id": 8, + "first_frame": "Eye-level wide shot back in the original bright stainless-steel culinary test kitchen from Shot 1: same counters, hanging pans, spice cabinet, and soft morning-like light. The same chef (identical adult identity, scar, hairstyle, and exact outfit) stands centered at the main prep island. In front of him: a single composed fusion dish plated neatly, plus the tan leather notebook opened to a page filled with small, tidy notes and icons. Mood: uplifting, accomplished; clean and family-friendly.", + "video_prompt": "Eye-level wide shot, 28mm lens, slow circular dolly clockwise around the chef. He presents the finished plate by sliding it forward slightly, then gestures to the open notebook as if connecting the international techniques. He takes one calm tasting bite, smiles subtly, and closes the notebook with the elastic strap—climax and resolution in a single confident finish as light glints off the stainless steel." + } + ] + } + ], + "metadata": { + "theme_key": "chef_international_kitchens", + "theme_description": "A chef exploring different international kitchens", + "consistency_type": "Type A", + "requested_scenes": 2, + "requested_shots": 8 + } +} diff --git a/vimax_benchmark/colleagues_brainstorming_ideas_typeC.json b/vimax_benchmark/colleagues_brainstorming_ideas_typeC.json new file mode 100644 index 0000000..e79968d --- /dev/null +++ b/vimax_benchmark/colleagues_brainstorming_ideas_typeC.json @@ -0,0 +1,108 @@ +{ + "story_overview": "Three adult colleagues collaborate in a modern meeting room, moving from a stuck start to an energetic brainstorming session, culminating in a clear, agreed creative concept captured on a whiteboard and celebrated with a team alignment.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a modern meeting room with a large oval light-wood table, glass wall with frosted banding, a whiteboard on wheels at frame right, a wall-mounted display at frame left, and warm overhead LED panel lighting. Three adults are present: Character A (a 29-year-old woman, tall and slim, light skin, straight blonde bob haircut, wearing a modest red blazer over a white high-neck blouse and dark tailored trousers) sits on the left side of the table with a closed notebook. Character B (a 35-year-old man, shorter and stocky, medium-brown skin, neatly trimmed dark beard, wearing a modest cobalt-blue sweater vest over a light gray button-down shirt and khaki trousers) sits near the center with a capped marker in hand. Character C (a 41-year-old woman, average height, East Asian features, long black hair in a low ponytail, wearing a modest forest-green cardigan over a black crew-neck top and a knee-length charcoal skirt with opaque tights) sits on the right with a laptop open. Coffee mugs, sticky notes, and pens are neatly arranged; mood is calm but slightly tense.", + "video_prompt": "Eye-level wide shot, 24mm lens. The trio exchange brief glances and small nods; Character B uncaps the marker and taps it lightly on a notepad, Character A slides her notebook closer, and Character C rotates her laptop slightly toward the others. Subtle chair shifts and hand movements communicate they are about to start; warm LED lighting stays steady and soft." + }, + { + "shot_id": 2, + "first_frame": "Medium two-shot from the table’s near corner: Character A (red blazer, blonde bob) and Character B (blue sweater vest, dark beard) are in the foreground, facing each other across the table edge. Character A holds a pen above her notebook; Character B holds the marker upright. The whiteboard is blurred in the background; Character C is visible out of focus beyond them with her laptop. Lighting is warm and even, with gentle reflections on the tabletop.", + "video_prompt": "Eye-level medium two-shot, 50mm lens. Character A gestures with her pen toward a blank page, then pauses; Character B responds with a small shrug and points the marker toward the whiteboard, suggesting a new direction. Their expressions shift from uncertain to curious, while Character C in the background leans in slightly, indicating attention." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder shot from behind Character C (green cardigan, low ponytail), framing her laptop screen edge and the two colleagues across the table. Character A is left of frame, Character B center-right. A small cluster of sticky notes sits near the laptop. The room’s glass wall and display screen reflect soft light; mood is focused.", + "video_prompt": "Over-the-shoulder shot, 35mm lens. Character C scrolls briefly on her laptop, then turns the laptop a few degrees to show a simple list template. Character A leans forward a little, and Character B nods once, raising the marker as if to start listing ideas. The camera remains steady, emphasizing shared focus." + }, + { + "shot_id": 4, + "first_frame": "Tight close-up of hands at the tabletop: Character B’s hand (blue vest sleeve visible) places the uncapped marker next to a stack of colorful sticky notes; Character A’s hand (red blazer sleeve) slides two sticky notes toward the center; Character C’s hand (green cardigan sleeve) places a small hourglass-style desk timer beside a mug. The wood grain and paper textures are crisp; lighting is warm with soft highlights.", + "video_prompt": "Tabletop macro close-up, 85mm lens. Hands coordinate: Character A pushes the sticky notes into a neat pile, Character B picks one up and flicks the marker cap into his palm, and Character C flips the timer once so sand begins to flow. The small, synchronized motions signal the brainstorming session officially starting." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Wide shot from near the whiteboard: the board dominates frame right, with Character B (blue sweater vest, dark beard) standing beside it, marker poised. Character A (red blazer, blonde bob) stands near the table holding a cluster of sticky notes. Character C (green cardigan, low ponytail) stands closer to the display screen with her laptop now closed, holding a thin notepad. The room feels brighter as the overhead LEDs reflect off the whiteboard surface.", + "video_prompt": "Eye-level wide shot, 28mm lens. Character B writes the first big heading on the whiteboard in clear block letters (unreadable from distance but visibly bold). Character A steps forward and offers sticky notes; Character B takes one and presses it onto the board. Character C gestures toward the display, then turns back to the board, aligning attention on the shared workspace." + }, + { + "shot_id": 6, + "first_frame": "Medium shot, three-quarter angle on the whiteboard: Character B is in profile writing; Character A stands slightly behind him, watching the marker tip; Character C stands to the side, pointing at a sticky note already placed. The board shows a few sticky notes arranged in a small grid. Clothing remains distinct: red blazer, blue sweater vest, green cardigan.", + "video_prompt": "Eye-level medium shot, 45mm lens. Character B finishes a short line, then steps half a pace back. Character A reaches in to reposition one sticky note higher; Character C taps a different note with one finger, suggesting grouping. The trio’s hands briefly converge near the board without overlapping identities, then separate as they agree on placement." + }, + { + "shot_id": 7, + "first_frame": "Over-the-shoulder from behind Character A (red blazer visible), looking toward Character B and Character C across the table edge. Character B holds the marker like a pointer; Character C holds a notepad and pen. The timer and mugs sit in the midground. The mood is building with lively attention.", + "video_prompt": "Over-the-shoulder shot, 40mm lens. Character A raises a sticky note and reads it silently, then hands it across the table edge toward Character B. Character B accepts it and points with the marker toward an empty space on the whiteboard. Character C nods and makes a quick note, eyes moving between the sticky note and the board." + }, + { + "shot_id": 8, + "first_frame": "Close-up of Character C (green cardigan, East Asian features, low ponytail) at the side of the table, softly lit by overhead LEDs. She looks thoughtful, lips pressed in concentration. Behind her, out of focus, Character A (red blazer) and Character B (blue vest) stand near the whiteboard.", + "video_prompt": "Eye-level close-up, 85mm lens. Character C’s expression shifts from thoughtful to inspired; she raises her index finger slightly, then turns her head toward the others and begins speaking. In the blurred background, Character A pivots to face her and Character B pauses his writing, signaling they are listening." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Wide shot from the far end of the room with the glass wall behind the camera: all three are visible around the whiteboard and table. The whiteboard now contains multiple sticky notes in columns. Character A (red blazer) stands nearest the board holding a marker cap; Character B (blue sweater vest) holds the marker; Character C (green cardigan) stands closer to the table with a small stack of notes. The mood is energetic and collaborative.", + "video_prompt": "Eye-level wide shot, 26mm lens. Character C steps forward and places two sticky notes onto the board; Character B adjusts them into alignment; Character A points to the top row as if naming categories. Their movements are coordinated, with clear spacing so identities remain distinct. The camera stays locked while the action fills the frame." + }, + { + "shot_id": 10, + "first_frame": "High-angle shot looking down at the table: a large sheet of paper (or flipchart pad page) lies centered with a simple hand-drawn mind map forming. Character A’s red blazer sleeve enters from left as she writes with a pen; Character B’s blue vest sleeve enters from top as he places a sticky note; Character C’s green cardigan sleeve enters from right as she slides a ruler-like straightedge to tidy lines. The wood tabletop texture is prominent.", + "video_prompt": "Overhead top-down shot, 24mm lens. The mind map grows: Character A draws a new branch, Character B adds a sticky note label, and Character C straightens the paper and taps a corner to keep it flat. Hands move in turn-taking rhythm, avoiding overlap, emphasizing structured collaboration." + }, + { + "shot_id": 11, + "first_frame": "Medium over-the-shoulder from behind Character B (blue sweater vest visible), facing Character A and Character C seated now at the table. Character A (red blazer) leans forward with her notebook open; Character C (green cardigan) sits upright with a pen poised. The whiteboard is visible behind them with organized columns of sticky notes.", + "video_prompt": "Over-the-shoulder shot, 50mm lens. Character B speaks while gesturing with the marker toward the whiteboard; Character A responds by circling a phrase in her notebook; Character C points gently at the mind map sheet on the table, guiding attention back to the core concept. The trio’s eye-lines track between board and paper in a clear feedback loop." + }, + { + "shot_id": 12, + "first_frame": "Tight close-up of the whiteboard surface: several sticky notes are arranged neatly under two bold headings (text not fully legible). Character A’s hand (red blazer sleeve) presses one note flat; Character B’s hand (blue vest sleeve) draws a clean rectangle around a cluster; Character C’s hand (green cardigan sleeve) adds a small check mark next to one note using a fine-tip pen. The board’s glossy texture shows soft reflections.", + "video_prompt": "Close-up, 70mm lens. Final organization happens quickly: Character B completes the rectangle, Character A shifts one sticky note into the boxed cluster, and Character C adds a second check mark beside another note. The cluster visually reads as the leading idea set, signaling they are converging." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Wide shot with the whiteboard centered: the trio stand shoulder-to-shoulder a step back from the board, all clearly visible. Character A (red blazer, blonde bob) stands left holding her notebook to her chest; Character B (blue sweater vest, dark beard) stands center holding the marker down at his side; Character C (green cardigan, low ponytail) stands right holding a notepad. The sticky notes are now arranged into a clean final layout with one central boxed cluster. Mood: confident and upbeat.", + "video_prompt": "Eye-level wide shot, 30mm lens. Character B gestures to the boxed cluster as the ‘final direction’; Character A nods decisively and lightly taps her notebook with a finger; Character C smiles subtly and points to a single sticky note that reads as the key message (not legible, but clearly emphasized by her gesture). Their posture relaxes into agreement." + }, + { + "shot_id": 14, + "first_frame": "Medium two-shot from the side: Character A (red blazer) and Character C (green cardigan) face each other near the table edge, with the whiteboard blurred behind them. Character A holds a single sticky note; Character C holds a pen and notepad. Character B (blue sweater vest) is visible in the background, slightly out of focus, placing the marker onto the tray of the whiteboard.", + "video_prompt": "Eye-level medium two-shot, 55mm lens. Character A hands the sticky note to Character C; Character C accepts it and writes a brief summary line on her notepad. In the background, Character B sets the marker down and straightens the whiteboard tray, signaling wrap-up. The exchange reads as a clean handoff from ideation to documentation." + }, + { + "shot_id": 15, + "first_frame": "Close, front-facing three-shot at the table: all three adults are seated again with the finalized mind map sheet placed neatly in the center. Character A (red blazer) sits left with pen resting; Character B (blue sweater vest) sits center with hands folded; Character C (green cardigan) sits right with notepad closed. Warm overhead lighting creates a professional, cozy tone; the whiteboard with organized sticky notes is visible in the background as proof of progress.", + "video_prompt": "Eye-level close three-shot, 35mm lens. They perform a brief team alignment: Character C slides the mind map sheet slightly toward the center, Character B nods once and offers an open-palmed gesture of agreement, and Character A gives a small thumbs-up-like approval gesture with her pen hand (non-exaggerated). They share a final satisfied look toward the whiteboard, concluding the brainstorming climax with clear consensus." + } + ] + } + ], + "metadata": { + "theme_key": "colleagues_brainstorming_ideas", + "theme_description": "Colleagues brainstorming creative ideas in a meeting room", + "consistency_type": "Type C", + "requested_scenes": 4, + "requested_shots": 15 + } +} diff --git a/vimax_benchmark/cooking_competition_restaurant_typeB.json b/vimax_benchmark/cooking_competition_restaurant_typeB.json new file mode 100644 index 0000000..bf82be7 --- /dev/null +++ b/vimax_benchmark/cooking_competition_restaurant_typeB.json @@ -0,0 +1,98 @@ +{ + "story_overview": "In a modern restaurant kitchen set for a friendly cooking competition, two adult chefs race to plate their signature dishes while a judge observes. The action intensifies through timed prep, coordinated movement, and careful plating, culminating in a final tasting and winner announcement—all within one consistent, detailed kitchen environment.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a modern restaurant kitchen interior, geometry locked: a central stainless-steel island with an induction cooktop and overhead vent hood; left wall has a row of ovens and a heat lamp pass; right wall has a double-door fridge, open metal shelving with neatly stacked bowls, and a hand-sink. Back wall features a glass door to a dry pantry. On the island: two identical cutting boards (one at each end), two mixing bowls, a digital timer, a basket of vegetables, and a tray of herbs. Three adults (20+): Chef A (adult woman, 30s, medium height, curly dark hair tied back, white chef jacket, black chef pants, black clogs), Chef B (adult man, 40s, tall, short brown hair, neatly trimmed beard, white chef jacket, black chef pants, black clogs), and Judge (adult woman, 50s, short silver hair, navy blazer over a modest blouse and slacks) stand near the island. Bright neutral LED ceiling lighting, clean reflective steel surfaces, upbeat professional mood.", + "video_prompt": "Eye-level wide shot, 24mm lens, locked-off camera. The judge gestures toward the digital timer on the island, nodding to both chefs. Chef A and Chef B step in from opposite sides of the island, placing their hands near their cutting boards without touching tools yet. The timer display is visible as the judge points, setting the start. No camera movement; only character motion within the fixed kitchen layout." + }, + { + "shot_id": 2, + "first_frame": "Medium shot from the right side of the island, framing the fridge and open shelving in the background (unchanged placement). Chef A stands foreground right at her cutting board, her bowl and herbs to her left. Chef B is visible midground left across the island. The digital timer sits centered on the island edge, readable. Stainless surfaces gleam under even LED lighting.", + "video_prompt": "Eye-level medium shot, 50mm lens. Chef A presses the digital timer start button with one finger; the numbers begin counting down. Chef B reacts with a focused nod, reaching for his knife handle but not lifting it yet. Chef A pulls her chef knife closer. Subtle handheld micro-movement for energy, while the kitchen geometry remains fixed." + }, + { + "shot_id": 3, + "first_frame": "Overhead top-down view centered on the island: two cutting boards at opposite ends, the timer in the middle, vegetables (carrots, zucchini, bell peppers) arranged in a basket near Chef A’s side, and a tray of herbs near Chef B’s side. The induction cooktop is visible built into the island with clean black glass. Hands of both chefs hover at their stations. Lighting is bright and shadow-minimal.", + "video_prompt": "Overhead static shot, 18mm equivalent top-down. Both chefs begin prep simultaneously: Chef A slides carrots onto her board and starts a careful dice; Chef B gathers herbs and begins a fine chop. Their hands move briskly but controlled. The timer digits tick down in the center, anchoring the action. No change to the room layout; only tools and ingredients shift position on the island." + }, + { + "shot_id": 4, + "first_frame": "Low-angle shot near the oven row on the left wall, looking toward the island and the overhead vent hood. The ovens, heat lamp pass, and island remain in fixed positions. Chef B crosses from the island toward the ovens, carrying a modest metal sheet tray. Chef A remains at the island in background, continuing to chop. The judge stands near the pass, hands clasped, observing.", + "video_prompt": "Low-angle wide shot, 28mm lens. Chef B walks behind the island’s near corner, partially occluded by the island edge as he passes. He reaches the ovens on the left wall and slides the sheet tray onto a rack with a smooth motion. Chef A stays in the background, chopping steadily. The judge leans slightly to keep Chef B in view. Camera remains low and steady, emphasizing depth and occlusion." + }, + { + "shot_id": 5, + "first_frame": "Medium shot from behind a structural element: the island’s overhead vent hood support column is foreground left, partially blocking the view. Chef A is midground at her station, visible through the gap, stirring ingredients in a metal pan placed on the induction cooktop. Chef B is background near the ovens. The fixed sink and shelving on the right wall remain visible in peripheral view.", + "video_prompt": "Eye-level medium shot, 70mm lens, slight parallax as the camera gently slides 10–15 cm to the right. Chef A turns on the induction zone; a small indicator light glows on the cooktop. She stirs with a wooden spoon, then adds diced vegetables. Steam begins to rise lightly. Chef B returns from the ovens toward the island, briefly disappearing behind the vent hood support (occlusion) before reappearing." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 6, + "first_frame": "Two-shot at the central island from the pantry-door side (back wall). The glass pantry door is centered in the background, unchanged. Chef A and Chef B stand on opposite sides of the island, each with a mixing bowl and utensils. The judge stands to the right near the sink area, holding a clipboard. Timer remains centered on the island. Bright, clean lighting.", + "video_prompt": "Eye-level two-shot, 35mm lens. Both chefs whisk simultaneously in their bowls. Chef B reaches across the island to grab a pinch of herbs from the shared tray, careful not to cross into Chef A’s cutting board area. Chef A glances at the timer, then increases whisking speed. The judge watches and makes a small note on the clipboard. Camera stays fixed to preserve the room’s locked geometry." + }, + { + "shot_id": 7, + "first_frame": "Close-up on Chef B’s hands at the island, with the fixed background softly visible: the ovens on the left wall and the vent hood above the cooktop remain in place. Chef B holds a small saucepan and pours a thin sauce into it from a ladle; a folded kitchen towel lies neatly beside the pot. No mess, professional setup.", + "video_prompt": "Eye-level close-up, 85mm lens. Chef B tilts the saucepan, swirling the sauce to coat the bottom, then sets it on the induction cooktop. He adjusts a knob with two fingers. The sauce begins a gentle simmer, forming small bubbles. The camera holds tight focus on the hands and the saucepan; background remains stable and consistent." + }, + { + "shot_id": 8, + "first_frame": "Wide shot from the right wall near the sink, capturing the sink faucet foreground right, open shelving midground, and the island centered. Chef A moves from the island toward the sink holding a bowl; Chef B is midground at the island. The judge stands near the pass on the left, unchanged in position relative to the set.", + "video_prompt": "Eye-level wide shot, 24mm lens. Chef A walks toward the sink, passing behind the sink faucet so her torso is briefly occluded by the fixture (depth test). She rinses a small bunch of herbs quickly, shakes off water, and returns toward the island, again partially hidden by the faucet for a moment. Chef B continues stirring at the island. The kitchen structure and fixtures remain perfectly fixed." + }, + { + "shot_id": 9, + "first_frame": "Medium shot angled from the left toward the heat lamp pass. The pass counter is foreground left with empty white plates stacked neatly. Chef A approaches the pass holding a clean plate; Chef B is behind her near the island. The judge stands behind the pass, ready to inspect. Ovens and island remain in their fixed positions.", + "video_prompt": "Eye-level medium shot, 50mm lens. Chef A sets a plate onto the pass counter with a careful, audible placement. She leans lightly against the counter edge for a second while checking a garnish in her hand (contact with environment). Chef B steps into frame carrying a second plate, stopping just behind Chef A without bumping. The judge nods, indicating plating can begin soon." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Overhead shot of the island focused on plating: two empty plates now sit near the pass-side edge of the island, with squeeze bottles, tweezers, and a small ramekin of microgreens arranged neatly. Timer is still centered. Chef A’s pan sits on the cooktop; Chef B’s saucepan is nearby. The fixed vent hood and island geometry are implied by the consistent top-down layout.", + "video_prompt": "Overhead static shot, 18mm top-down. Chef A spoons sautéed vegetables onto her plate in a tidy mound; Chef B uses a spoon to place sauce in a smooth arc. Both use plating tweezers carefully to add microgreens. The timer counts down toward the final seconds, increasing urgency. All movement is confined to hands and tools on the unchanging island." + }, + { + "shot_id": 11, + "first_frame": "Dramatic low-angle shot from the floor near the island base, looking up at the chefs and the underside of the vent hood. The island blocks part of their bodies (occlusion). Chef A reaches across to slide her plate toward the pass; Chef B mirrors the action from the opposite side. The judge is visible beyond the island near the pass.", + "video_prompt": "Low-angle wide shot, 20mm lens. Chef A and Chef B each slide a finished plate across the island’s steel surface toward the pass-side edge. Their hands briefly disappear behind the island lip (occlusion), then reappear as plates reach the edge. The judge steps forward one pace, aligning with the plates. Camera remains low and steady, emphasizing the solidity and depth of the environment." + }, + { + "shot_id": 12, + "first_frame": "Medium close-up at the pass counter: two plated dishes sit under the heat lamp, steam faintly visible. The judge stands behind the pass, clipboard in hand. Chef A and Chef B stand side-by-side in the background, hands clasped in front, maintaining modest professional posture. The pass, plates, and lamp are fixed in the same location as earlier shots.", + "video_prompt": "Eye-level medium close-up, 60mm lens. The judge leans in, inspecting both plates, then uses a fork to take a small bite from Chef A’s dish, followed by Chef B’s. She pauses thoughtfully, eyes shifting between plates, then writes a brief note on the clipboard. The chefs remain still, watching for the decision." + }, + { + "shot_id": 13, + "first_frame": "Tight reaction two-shot of the chefs at the island with the fixed pantry door in the background. Chef A and Chef B stand on the same side now near the timer, shoulders squared, calm but expectant. The timer shows near completion. Stainless reflections remain consistent, lighting steady.", + "video_prompt": "Eye-level close two-shot, 75mm lens. The chefs exchange a quick respectful glance, then look toward the judge off-camera. Chef A exhales slowly; Chef B gives a small encouraging nod. The timer beeps softly at the end of the countdown. Camera holds steady, focusing on facial reactions without changing the room geometry." + }, + { + "shot_id": 14, + "first_frame": "Wide final shot matching the kitchen layout: central island in the middle, ovens on the left wall, fridge and shelving on the right, pantry door at back, pass counter at left foreground. The judge stands by the pass holding the clipboard; Chef A and Chef B stand facing her near the island. Two plated dishes remain under the heat lamp. Bright, celebratory but professional mood.", + "video_prompt": "Eye-level wide shot, 24mm lens, locked-off. The judge steps forward and announces the winner with a warm gesture toward Chef A, then offers a firm handshake to Chef A first and to Chef B second (friendly, sportsmanlike). Chef B applauds lightly. Chef A nods appreciatively. The camera remains fixed, showcasing the unchanged 3D kitchen environment as the story concludes." + } + ] + } + ], + "metadata": { + "theme_key": "cooking_competition_restaurant", + "theme_description": "A cooking competition in a modern restaurant kitchen", + "consistency_type": "Type B", + "requested_scenes": 3, + "requested_shots": 14 + } +} diff --git a/vimax_benchmark/couple_cooking_dinner_together_typeC.json b/vimax_benchmark/couple_cooking_dinner_together_typeC.json new file mode 100644 index 0000000..ce71d41 --- /dev/null +++ b/vimax_benchmark/couple_cooking_dinner_together_typeC.json @@ -0,0 +1,68 @@ +{ + "story_overview": "Two adults cook dinner together in a cozy home kitchen, coordinating tasks, passing ingredients, and timing the meal so everything finishes at once, ending with a warm, shared plating moment.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide two-shot of a tidy, warmly lit home kitchen at dusk. Character A: a 32-year-old woman, tall and slim, medium-brown skin, curly dark hair in a low bun, wearing a forest-green cardigan over a white crew-neck shirt, dark jeans, and a light-gray apron; small silver stud earrings; calm focused expression. Character B: a 34-year-old man, average height with a sturdy build, light skin, short black hair, neatly trimmed beard, wearing a navy button-down shirt with sleeves rolled to forearms, tan chinos, and a charcoal apron; attentive expression. They stand side-by-side at a wooden island: cutting board, chef’s knife, tomatoes, bell pepper, onion, garlic, a bowl of salad greens, a bottle of olive oil, and a smartphone on a stand showing a recipe. Background: stove with a covered pot, tiled backsplash, hanging utensil rail, and a window with soft evening light. Both are modestly dressed; no revealing clothing.", + "video_prompt": "Eye-level wide two-shot on a 24mm lens from the kitchen entry, static tripod. Character A gestures toward the smartphone recipe while Character B nods and points to the ingredients; they lean in together, then separate to their stations—A turns toward the island to prep vegetables while B steps toward the stove, lifting the pot lid briefly to check steam, then setting it back down." + }, + { + "shot_id": 2, + "first_frame": "Over-the-shoulder shot from behind Character A (green cardigan visible at frame left), focusing on the cutting board on the island. Character A’s hands hold a tomato steady; Character B appears in the background right at the stove, slightly out of focus, stirring a pot. Warm under-cabinet lighting highlights the knife blade and the tomato’s glossy skin; the smartphone recipe is visible on its stand near the board.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens, slight handheld sway. Character A slices tomatoes into even wedges with controlled movements, sliding pieces into a bowl. In the background, Character B stirs steadily, then glances toward A as if checking timing, returning to the pot." + }, + { + "shot_id": 3, + "first_frame": "Reverse over-the-shoulder shot from behind Character B (navy shirt collar and charcoal apron strap visible), aimed toward the stove and pot. Character B holds a wooden spoon above a simmering pot; Character A is in the background at the island, washing greens in a colander. Soft steam rises; the backsplash tiles and utensil rail remain crisp and stable.", + "video_prompt": "Over-the-shoulder medium shot, 35mm lens, gentle push-in. Character B stirs and reduces the heat, then reaches to the side for a spice jar. In the background, Character A shakes the colander dry and sets it on the counter, watching B for a cue." + }, + { + "shot_id": 4, + "first_frame": "Medium two-shot at the island from a slight high angle. Character A holds a small bowl of chopped onion; Character B stands beside her holding a measuring spoon and an open spice jar. Their aprons are clearly distinct: A’s light-gray apron, B’s charcoal apron. The cutting board is neatly arranged with chopped vegetables, olive oil, and a lemon.", + "video_prompt": "Slight high-angle medium two-shot, 28mm lens, static. Character B reads the recipe on the smartphone, then measures spices carefully. Character A tilts the onion bowl forward as if ready to add; both exchange quick eye contact and a small smile, then B gestures for her to wait one beat while he finishes measuring." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Close-up on hands at the island: Character B’s hands (navy cuff visible) hold a small ramekin of spices; Character A’s hands (green cardigan cuff visible) hold a lemon half over a small bowl. The background is softly blurred, but both aprons’ colors remain distinguishable. Warm light reflects off the bowl’s rim and the lemon’s textured peel.", + "video_prompt": "Close-up, 85mm lens, locked-off. Character A squeezes the lemon, a few drops falling into the bowl, then sets it down. Character B slides the ramekin toward A; A accepts it and places it beside the vegetables, completing a clean handoff without mixing their sleeves or aprons." + }, + { + "shot_id": 6, + "first_frame": "Medium shot from the side showing the stove and island in the same frame with strong depth. Character B stands at the stove front-right, holding the pot lid slightly ajar; Character A steps in from the left carrying a bowl of chopped vegetables. Steam rises gently; the pot handle and burner grates are clearly visible.", + "video_prompt": "Side-angle medium shot, 35mm lens, slow lateral pan following the action. Character A walks behind Character B (brief occlusion as she passes), then emerges at the stove’s left side. Character B lifts the lid fully; Character A pours vegetables into the pot in a smooth arc, and B stirs immediately to incorporate them, then lowers the lid halfway." + }, + { + "shot_id": 7, + "first_frame": "Overhead shot of the island with both characters’ torsos visible at opposite edges: Character A (green cardigan, light-gray apron) on the left, Character B (navy shirt, charcoal apron) on the right. A large salad bowl sits center with greens; a small bottle of olive oil and a pepper grinder are near Character B; sliced tomatoes and cucumber are near Character A.", + "video_prompt": "Overhead top-down shot, 24mm lens, steady. Character A adds sliced tomatoes and cucumber into the salad bowl while Character B drizzles olive oil in a thin stream and twists the pepper grinder. They coordinate without crossing tools; their hands briefly meet at the bowl rim as they rotate it together, then separate cleanly." + }, + { + "shot_id": 8, + "first_frame": "Tight two-shot at the stove, eye-level, showing both faces and the pot between them. Character B holds a wooden spoon; Character A holds a small tasting spoon. The kitchen light is slightly brighter now, reflecting off the pot lid. Both look attentive and calm, leaning in modestly without any suggestive framing.", + "video_prompt": "Eye-level tight two-shot, 50mm lens, slight push-in. Character B stirs and pauses; Character A carefully dips the tasting spoon, cools it with a small wave, then tastes. She nods, gestures a small 'just a pinch' sign; Character B adds a tiny amount from a pinch bowl, stirs again, and both relax with relieved smiles—this is the timing-and-seasoning climax." + }, + { + "shot_id": 9, + "first_frame": "Wide shot of the dining nook adjacent to the kitchen. A small table is set with two plates, cloth napkins, and water glasses. Character A carries a salad bowl; Character B carries a covered serving dish from the stove. Warm, cozy lighting; no clutter. Their outfits remain unchanged and distinct.", + "video_prompt": "Eye-level wide shot on a 24mm lens from the dining nook, static. Character A sets the salad bowl down and adjusts the serving spoon. Character B places the covered dish at the center, lifts the lid to reveal steam (no mess), then they jointly plate portions—passing a serving spoon once—ending with both sitting down at the table, exchanging a satisfied look as the action settles." + } + ] + } + ], + "metadata": { + "theme_key": "couple_cooking_dinner_together", + "theme_description": "A couple preparing dinner together in their kitchen", + "consistency_type": "Type C", + "requested_scenes": 2, + "requested_shots": 9 + } +} diff --git a/vimax_benchmark/dancer_architectural_spaces_typeA.json b/vimax_benchmark/dancer_architectural_spaces_typeA.json new file mode 100644 index 0000000..b7a0ac4 --- /dev/null +++ b/vimax_benchmark/dancer_architectural_spaces_typeA.json @@ -0,0 +1,63 @@ +{ + "story_overview": "A single contemporary dancer rehearses a short piece, adapting the same precise costume and identity across dramatically different architectural spaces, building from quiet preparation to a confident final performance under stage lights.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "A 28-year-old woman contemporary dancer with warm brown skin, almond-shaped dark eyes, a small crescent-shaped scar just above her left eyebrow, and black hair in a tight low bun. She wears a modest matte-black long-sleeve mock-neck leotard, loose black ankle-length dance pants, and soft black dance shoes; no jewelry. She stands alone in a bright modern rehearsal studio with pale maple sprung floor, white walls, tall frosted windows, and a mirrored wall to camera-left. A simple canvas tote and a capped water bottle sit near the back wall. Morning sunlight creates soft rectangular window highlights on the floor. She faces the mirror in a calm, focused posture, hands resting at her sides.", + "video_prompt": "Eye-level medium-wide shot (35mm), locked-off camera facing the dancer and mirror at a slight angle. She inhales, rolls her shoulders, and marks the opening counts with small, precise arm pathways, then takes two quiet steps forward; subtle reflections move in the mirror. Sunlight glows and dust motes drift; no other people enter." + }, + { + "shot_id": 2, + "first_frame": "The same 28-year-old woman dancer (crescent scar above left eyebrow, low bun; same modest all-black long-sleeve leotard, loose pants, black dance shoes) now inside a vast minimalist concrete atrium with high ceilings, exposed concrete walls, and a long skylight overhead. A shallow reflecting pool runs along camera-right, and a thin line of indoor trees in planters sits in the far background. Cool daylight pours from above, casting crisp shadows. She stands near the edge of the pool, centered in frame, body angled three-quarters toward camera.", + "video_prompt": "High-angle wide shot (24mm) from an upper balcony looking down. She begins a traveling phrase: a controlled pivot, then a sweeping side reach that arcs over the pool’s reflection; she steps along the pool edge with measured footwork. The water surface ripples gently from air movement, mirroring her silhouette; the camera remains steady while her path shifts slightly left-to-right." + }, + { + "shot_id": 3, + "first_frame": "The same dancer (28, crescent scar above left eyebrow; same black long-sleeve leotard, loose pants, black dance shoes) in a historic grand hall with warm honey-colored stone, a coffered ceiling, and tall arched windows draped with cream curtains. Ornate but tasteful chandeliers hang high above. The polished stone floor reflects light. Golden late-afternoon sun streams in, creating long diagonal beams. She stands near a central column, fingertips lightly touching the stone for balance, gaze forward and intent.", + "video_prompt": "Low-angle medium shot (50mm) near the base of the column, looking up slightly. She pushes away from the column into a smooth turn, then a gentle, grounded leap with arms extended in a soft V, landing quietly and continuing into a spiraling torso contraction. The chandelier light twinkles overhead; her shadow stretches across the reflective floor as she completes the phrase." + }, + { + "shot_id": 4, + "first_frame": "The same dancer (28, crescent scar above left eyebrow; same modest all-black dance outfit and shoes) on an open-air rooftop terrace at night. Surrounding her are geometric glass railings, a metal pergola, and distant city buildings with windows glowing. Cool blue moonlight mixes with warm amber string lights wrapped neatly around the pergola beams. A light breeze lifts the fabric of her loose pants slightly. She stands near the pergola posts, feet planted, arms raised overhead as if preparing for a more daring sequence.", + "video_prompt": "Dutch-tilted medium-wide shot (28mm), camera positioned waist-high, angled upward to include pergola lines and skyline. She performs a faster sequence: a quick triple step, a sharp directional change under the pergola, then a controlled drop into a low lunge and rise, arms carving through the air. String lights shimmer; the breeze subtly moves her clothing and loose wisps of hair while the camera holds the dynamic tilted composition." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "The same dancer (28, crescent scar above left eyebrow; low bun; same black long-sleeve leotard, loose pants, black dance shoes) in a quiet museum gallery with white walls and a grid of framed architectural sketches. A long wooden bench sits mid-ground. Track lights create focused pools of neutral light and soft shadows. She stands beside the bench, one hand hovering above the bench’s backrest, as if using it as a rehearsal partner. The mood is hushed and contemplative.", + "video_prompt": "Side-profile medium shot (50mm), camera at eye level, bench leading lines toward the back wall. She places her palm lightly on the bench for a moment, then slides her hand along it as she rotates, stepping around the bench’s end; she transitions into a balanced extension with a calm facial expression. The track lights remain steady; the framed sketches stay fixed as she moves through the light pools." + }, + { + "shot_id": 6, + "first_frame": "The same dancer (28, crescent scar above left eyebrow; same modest all-black outfit and shoes) inside a luminous glass greenhouse with curved steel ribs and condensation on panes. Tall potted palms and ferns create layered depth. Humid, diffused daylight gives a soft, milky glow; droplets cling to leaves. She stands on a narrow stone path between planters, framed by arching greenery, hands at chest height as if gathering energy.", + "video_prompt": "Slow push-in close-to-medium shot (70mm), camera at chest height aligned with the stone path. She begins a fluid wave through her arms and spine, then threads her body between two large fern fronds, careful not to touch them, and completes a slow turn with precise spotting. Leaves sway slightly from her passing; condensation sparkles as the camera advances a few feet, tightening the frame." + }, + { + "shot_id": 7, + "first_frame": "The same dancer (28, crescent scar above left eyebrow; same black long-sleeve leotard, loose pants, black dance shoes) in an unfinished timber-frame hall under construction: exposed wooden beams, temporary scaffolding, and clean plywood flooring. Sunlight slants through open wall sections, creating striped shadows. Safety cones and neatly stacked materials sit far in the background, out of her path. She stands centered beneath a tall triangular truss, arms lowered, chin lifted with determination, ready for the final run.", + "video_prompt": "Crane-like descending wide shot (24mm), starting high to reveal the timber geometry, then easing down to frame her under the truss. She launches into her most athletic phrase: a traveling diagonal with two light jumps, a controlled turn, then a deep grounded slide to one knee and a smooth recovery to standing. The striped sunlight moves across her as she crosses the floor; the camera’s descent emphasizes scale and momentum." + }, + { + "shot_id": 8, + "first_frame": "The same dancer (28, crescent scar above left eyebrow; low bun; same modest matte-black long-sleeve leotard, loose black pants, black dance shoes) on a professional theater stage with a clean wooden floor and a simple architectural set piece: three tall matte-gray rectangular frames arranged upstage like doorways. Warm amber spotlights create a bright circle center stage with soft falloff into darkness. A faint haze in the air catches the beams. She stands in the center spotlight, facing camera, composed and confident, hands relaxed at her sides—final performance moment.", + "video_prompt": "Eye-level medium shot (40mm) centered on the dancer in the spotlight. She performs the completed choreography with clarity: a precise opening gesture, a sustained turn that resolves into a strong still pose, then a final expansive reach toward the architectural frames behind her before returning to center. The haze gently swirls in the light beams; she finishes by holding a calm, grounded stance, breathing steady as the stage remains quiet and focused—climactic completion." + } + ] + } + ], + "metadata": { + "theme_key": "dancer_architectural_spaces", + "theme_description": "A dancer performing in different architectural spaces", + "consistency_type": "Type A", + "requested_scenes": 2, + "requested_shots": 8 + } +} diff --git a/vimax_benchmark/detective_witness_interview_typeC.json b/vimax_benchmark/detective_witness_interview_typeC.json new file mode 100644 index 0000000..edf13ae --- /dev/null +++ b/vimax_benchmark/detective_witness_interview_typeC.json @@ -0,0 +1,93 @@ +{ + "story_overview": "In a quiet police interview room, a methodical detective interviews a nervous witness about a missing community grant file. Through careful questioning and a collaborative reenactment using a table map and objects, they uncover a crucial detail that identifies where the file was last handled, leading to a clear next step in the investigation.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide two-shot in a modest, well-lit police interview room. Detective Mira Chen (adult woman, 34, East Asian, neat chin-length black bob, calm focused expression, wearing a charcoal blazer over a light blue button-up shirt, dark slacks, no visible jewelry) sits on the left side of a gray metal table with a closed tan folder, a pen, and a small digital voice recorder. Witness Jordan Hale (adult man, 29, light skin, short curly auburn hair, light freckles, clean-shaven, wearing a forest-green crewneck sweater over a white collared shirt, dark jeans) sits on the right, hands folded. A plain wall with a clock and a frosted glass observation window sits behind them. Both are modestly dressed, PG-rated mood.", + "video_prompt": "Eye-level wide shot, 24mm lens, locked-off camera. Detective Mira gently slides the small digital recorder to the center of the table and nods to begin; Jordan takes a steadying breath and straightens in his chair. Subtle room tone, soft overhead fluorescent lighting, neutral color palette." + }, + { + "shot_id": 2, + "first_frame": "Over-the-shoulder close-up from behind Detective Mira, framing Jordan’s upper body and face. The recorder and tan folder are blurred in the foreground. Jordan’s eyes flick toward the folder, his fingers interlaced tightly, expression anxious but cooperative.", + "video_prompt": "Over-the-shoulder close-up, 50mm lens, slight handheld micro-movement. Jordan speaks with small head nods, then briefly glances down at his hands; his fingers loosen and re-lace as he tries to recall details. Lighting remains soft and even, highlighting natural skin texture and sweater knit." + }, + { + "shot_id": 3, + "first_frame": "Reverse over-the-shoulder close-up from behind Jordan, framing Detective Mira’s face and shoulders. Mira’s charcoal blazer and light blue shirt collar are crisp. The tan folder sits near her left hand; the pen is aligned parallel to the table edge. Mira’s expression is attentive and composed.", + "video_prompt": "Over-the-shoulder close-up, 70mm lens, steady camera. Mira asks a precise question, tapping the pen once beside the folder for emphasis, then pauses and maintains steady eye contact. Subtle reflections on the tabletop; controlled, professional tone." + }, + { + "shot_id": 4, + "first_frame": "Insert shot of the tabletop: the small digital recorder centered, the closed tan folder to the left, a notepad to the right with a few neat lines, and Mira’s pen near the bottom edge. Jordan’s hands rest near the right edge of frame, sleeves of the green sweater visible.", + "video_prompt": "Top-down insert shot, 35mm lens, static camera. Mira’s hand enters frame to open the notepad and underline a key time; Jordan’s fingertips hover near the recorder but do not touch it, then retreat. Overhead light creates soft, diffused shadows and clear object edges." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Medium two-shot from a slight angle, showing both seated across the table. Mira has placed a simple printed map of a community center on the table between them (rooms labeled, black-and-white floor plan). A paper cup of water sits near Jordan. The frosted observation window is visible in the background.", + "video_prompt": "Eye-level medium two-shot, 35mm lens, gentle slow push-in. Mira slides the printed map toward Jordan and points to a hallway on the plan; Jordan leans forward, placing one finger near a marked door, indicating where he stood. The action is collaborative, calm, and focused." + }, + { + "shot_id": 6, + "first_frame": "Close-up on Jordan’s hands over the printed map. His index finger rests on a small labeled rectangle reading 'Office'. The knit texture of his green sweater cuffs is visible. Mira’s pen tip appears at the edge of frame, poised to mark a note on the margin.", + "video_prompt": "Close-up, 60mm lens, static camera. Jordan traces a short path along the hallway line on the map; Mira’s pen follows near his finger, stopping at a doorway. Their gestures coordinate precisely without overlap of identities; the paper crinkles softly." + }, + { + "shot_id": 7, + "first_frame": "Medium close-up on Detective Mira from Jordan’s side, her face in three-quarter view. She holds a small clear evidence bag containing a plain keycard (no logos), lifted at chest height. Her expression is gently inquisitive. Jordan is blurred in the near foreground edge.", + "video_prompt": "Eye-level medium close-up, 50mm lens, slight tilt as Mira raises the evidence bag into clearer view. Mira asks if Jordan recognizes the keycard; she rotates the bag slightly so the card catches the overhead light. The bag crinkles; lighting highlights plastic reflections." + }, + { + "shot_id": 8, + "first_frame": "Reaction close-up on Jordan’s face, framed tight. His freckles and auburn curls are clearly visible. His eyes widen with recognition; his posture leans forward slightly. Background falls into soft blur, with a hint of the frosted window.", + "video_prompt": "Eye-level close-up, 85mm lens, subtle handheld. Jordan nods once, then raises his right hand to gesture toward the map off-screen, speaking faster as he recalls a detail. His expression shifts from anxious to certain." + }, + { + "shot_id": 9, + "first_frame": "High-angle wide shot looking down at the entire table and both participants. The printed map lies centered; the tan folder is now open near Mira with a few papers inside (text not readable). The recorder remains near the middle. Mira and Jordan both lean in, hands visible but not touching each other.", + "video_prompt": "High-angle wide shot, 28mm lens, locked-off. Mira places the keycard bag gently on the map near the 'Office' label; Jordan points to a different room labeled 'Copy Room' and then to the hallway, indicating a route. Their coordinated gestures create a clear shared action while keeping identities distinct." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Low-angle medium two-shot from table level, emphasizing the recorder in the foreground and both faces above it. Mira sits upright, composed; Jordan sits forward, engaged. The room’s clock is visible on the wall behind them, showing late afternoon.", + "video_prompt": "Low-angle medium two-shot, 35mm lens, slow dolly left. Mira calmly summarizes the timeline; Jordan interjects with a specific time and nods emphatically. The recorder remains stationary as the focal foreground anchor; soft fluorescent lighting keeps a neutral, procedural mood." + }, + { + "shot_id": 11, + "first_frame": "Insert close-up of Mira’s notepad on the table. A simple timeline is drawn with neat handwriting and an empty checkbox next to 'Copy Room printer log'. Mira’s pen hovers above the checkbox. The edge of the printed map peeks in from the left.", + "video_prompt": "Top-down close-up insert, 55mm lens, static camera. Mira checks the box with one decisive stroke, then circles a time on the timeline. The pen lifts; paper fibers and ink edges are crisp under even overhead light." + }, + { + "shot_id": 12, + "first_frame": "Over-the-shoulder two-shot from behind Mira, framing Jordan and the open folder on the table. Jordan holds a small rectangular receipt slip (plain paper, no branding) between two fingers, extended across the table. Mira’s hand is open, ready to receive it; their hands are inches apart.", + "video_prompt": "Over-the-shoulder medium shot, 40mm lens, gentle push-in. Jordan passes the receipt slip to Mira; Mira takes it carefully and places it beside the map. This is the key shared action, clearly showing object transfer without attribute mixing. The moment feels like the turning point." + }, + { + "shot_id": 13, + "first_frame": "Wide concluding two-shot, eye-level, showing both seated as the tension eases. Mira stands partially from her chair, holding the receipt slip and the evidence bag with the keycard in one hand, the tan folder tucked under her other arm. Jordan sits back, shoulders lowered, hands resting calmly on the table. The room remains tidy and unchanged, with the frosted window and clock behind them.", + "video_prompt": "Eye-level wide shot, 24mm lens, locked-off. Mira offers a reassuring nod and explains the next step—checking the printer log and keycard access—then turns slightly toward the door as if to proceed. Jordan exhales with visible relief and gives a small confirming nod. Lighting stays soft and professional, ending on a clear investigative plan (climax: critical evidence revealed and direction set)." + } + ] + } + ], + "metadata": { + "theme_key": "detective_witness_interview", + "theme_description": "A detective interviewing a witness about a case", + "consistency_type": "Type C", + "requested_scenes": 3, + "requested_shots": 13 + } +} diff --git a/vimax_benchmark/family_gathering_victorian_mansion_typeB.json b/vimax_benchmark/family_gathering_victorian_mansion_typeB.json new file mode 100644 index 0000000..3504d58 --- /dev/null +++ b/vimax_benchmark/family_gathering_victorian_mansion_typeB.json @@ -0,0 +1,98 @@ +{ + "story_overview": "In a Victorian mansion’s grand parlor, an adult family gathers for a warm reunion, prepares a group toast, and culminates in a heartfelt moment as an old music box and a framed family photo bring everyone together for a keepsake picture.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Grand Victorian mansion parlor interior, unchanged layout for the entire story: a tall arched window with heavy burgundy drapes on the left wall, a carved marble fireplace centered on the far wall with a brass fireguard, a large gilded mirror above the mantel, and an antique mantel clock to the right. A round mahogany tea table sits center-left on a patterned rug, with a silver tea service and a covered cake stand. Two tufted armchairs face the fireplace at mid-right, and a long settee sits along the right wall beneath a gallery of framed family portraits. A spiral staircase is visible through a wide doorway at the back-left, leading upward. Warm amber chandelier light mixes with soft daylight from the window. Three adults (all 20+): a 42-year-old woman with dark hair in a neat bun wearing a forest-green cardigan and long navy skirt; a 45-year-old man with short salt-and-pepper hair wearing a charcoal blazer and light button-up shirt; a 25-year-old woman with curly auburn hair wearing a modest cream sweater and ankle-length brown skirt. They stand near the doorway, smiling, holding small gift bags.", + "video_prompt": "Eye-level wide shot on a 24mm lens from the parlor’s front-right corner, framing the fireplace, tea table, and doorway. Over 6 seconds, the three adults step into the room from the doorway, pausing to take in the parlor; they set the gift bags gently onto the edge of the tea table, glancing around with warm anticipation. The background architecture and furniture remain perfectly fixed and unchanged." + }, + { + "shot_id": 2, + "first_frame": "Same parlor geometry and furniture placement. Camera now closer to the tea table: the silver tea service gleams, cake stand centered, delicate porcelain cups aligned. The 42-year-old woman leans slightly toward the tea table, hands hovering over cups; the 25-year-old woman stands opposite her, attentive. The 45-year-old man is in the background near the settee, turning toward the staircase doorway as if expecting someone else. Lighting remains warm chandelier with soft window fill; polished wood reflects highlights.", + "video_prompt": "Eye-level medium shot on a 35mm lens aimed at the tea table. Over 5–7 seconds, the 42-year-old woman straightens two cups and adjusts a small plate; the 25-year-old woman nods and lightly rotates the cake stand a few inches to center it. In the background, the 45-year-old man takes two steps toward the doorway by the staircase, then stops. The room’s structural details remain stable; only the people and small table objects move." + }, + { + "shot_id": 3, + "first_frame": "Same parlor. Camera positioned near the staircase doorway looking into the room: the doorway framing shows the spiral staircase partially. The 45-year-old man stands near the doorway, half-turned toward the staircase. A fourth adult enters: a 48-year-old woman with shoulder-length gray-streaked hair wearing a burgundy knit sweater and a long black skirt, holding a wrapped box. She is stepping in from the staircase side. The tea table and fireplace remain visible deeper in frame, unchanged.", + "video_prompt": "Eye-level medium-wide shot on a 28mm lens from the staircase doorway. Over 6 seconds, the 48-year-old woman steps into the parlor and hands the wrapped box to the 45-year-old man; he accepts it with both hands. They exchange a brief, friendly greeting gesture (a small wave and a warm smile). They then pivot slightly toward the tea table, beginning to walk inward. The spiral staircase, doorway trim, and parlor furniture remain perfectly consistent." + }, + { + "shot_id": 4, + "first_frame": "Same parlor. Camera now from behind the settee on the right wall, looking toward the center. The settee backrest occupies the foreground lower-right. The 25-year-old woman walks behind the tea table toward the fireplace, partially occluded by the tea table and chair edges as she passes. The 42-year-old woman stands at the tea table. The 45-year-old man and 48-year-old woman are mid-room, moving toward the settee area. The portraits above the settee and the mirror above the fireplace are crisp and unchanged.", + "video_prompt": "Eye-level medium-wide shot on a 40mm lens from behind the settee, emphasizing depth and occlusion. Over 7 seconds, the 25-year-old woman crosses behind the tea table toward the fireplace, briefly disappearing behind the cake stand and chair backs; the 45-year-old man and 48-year-old woman walk forward into the foreground-right and stop near the settee, careful not to disturb it. The 42-year-old woman remains by the tea table, hands poised. The parlor’s layout and wall décor remain locked." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Same parlor, unchanged. Camera faces the right wall settee and portrait gallery. The 45-year-old man lowers himself onto the settee, sitting upright; the 48-year-old woman sits beside him, smoothing her skirt. In the mid-ground, the 42-year-old woman stands near an armchair, and the 25-year-old woman is near the fireplace mantel, looking at an ornate wooden music box resting on the mantel to the left of the clock. Warm light reflects off the polished settee wood trim.", + "video_prompt": "Eye-level medium shot on a 50mm lens centered on the settee. Over 6 seconds, the 45-year-old man and 48-year-old woman settle into seated positions (contact and weight visible as cushions compress slightly). In the background, the 25-year-old woman reaches toward the mantel, pausing before touching the music box. The architecture and furniture placement remain unchanged." + }, + { + "shot_id": 6, + "first_frame": "Same parlor. Close-up framing of the mantel area: the carved marble fireplace surround, gilded mirror edge, antique clock, and the small wooden music box with inlaid floral patterns. The 25-year-old woman’s modestly dressed torso and hands enter frame from the right; her fingers hover over the music box latch. The chandelier glow creates warm highlights on brass and wood grain.", + "video_prompt": "Eye-level close-up on an 85mm lens focused on the music box atop the mantel. Over 5–6 seconds, the 25-year-old woman gently opens the music box lid; the lid rises smoothly and stops. She turns a tiny key once, careful and slow. The mantel objects remain fixed in position; only the lid and her hands move." + }, + { + "shot_id": 7, + "first_frame": "Same parlor. Camera from near the tea table looking toward the settee and fireplace. The 42-year-old woman carries a tray with teacups from the tea table toward the armchairs, stepping behind one armchair so her lower body is partially occluded. The 45-year-old man and 48-year-old woman on the settee watch with appreciative smiles. The 25-year-old woman stands near the fireplace, music box open on the mantel.", + "video_prompt": "Eye-level medium-wide shot on a 32mm lens with the tea table foreground-left and settee mid-right. Over 7 seconds, the 42-year-old woman walks with the tray, passing behind the nearer armchair; her hands remain steady as the tray stays level. She emerges from behind the armchair and lowers the tray onto a small side surface beside the armchair. The room geometry, rug pattern, and furniture placement remain consistent." + }, + { + "shot_id": 8, + "first_frame": "Same parlor. Camera positioned low near the rug, aimed upward slightly at the round tea table. The 48-year-old woman stands from the settee and approaches the tea table, reaching for the covered cake stand. The 45-year-old man remains seated, visible behind her. The 42-year-old woman is near the armchair, and the 25-year-old woman is near the fireplace. The silver tea service reflects warm highlights; all background elements remain fixed.", + "video_prompt": "Low-angle medium shot on a 35mm lens from rug height, emphasizing the tea table’s legs and the cake stand. Over 6 seconds, the 48-year-old woman steps up to the table, places one hand on the cake stand lid knob, and lifts the lid carefully, revealing the cake inside. She sets the lid down on the table without shifting other items. The parlor’s architecture and furniture remain unchanged." + }, + { + "shot_id": 9, + "first_frame": "Same parlor. Two-shot composition near the fireplace: the 25-year-old woman stands at the mantel with the music box open; the 45-year-old man has stood up and walked closer, stopping beside her at a respectful distance. Both look down at the music box and then toward a framed family portrait on the mantel. The mirror above reflects chandelier light; the clock remains to the right.", + "video_prompt": "Eye-level two-shot on a 55mm lens, framing the two adults from waist-up with the mantel in view. Over 6–7 seconds, the 25-year-old woman gestures lightly toward the framed portrait; the 45-year-old man nods and points gently at a detail in the photo, then both share a quiet smile. The mantel objects remain in their exact positions; only their hands and head movements change." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Same parlor. Camera from the back-left doorway near the spiral staircase, looking toward the center. All four adults gather closer to the tea table: the 42-year-old woman stands at the table pouring tea; the 48-year-old woman holds a small dessert plate; the 45-year-old man holds a teacup; the 25-year-old woman approaches from the fireplace side. The chandelier glow is slightly brighter, giving a celebratory warmth; daylight remains soft through drapes.", + "video_prompt": "Eye-level wide shot on a 26mm lens from the doorway near the spiral staircase. Over 7 seconds, the 42-year-old woman pours tea into two cups, then sets the teapot down; the 25-year-old woman steps in to accept a cup with both hands. The 45-year-old man and 48-year-old woman adjust their stance around the table, forming a closer circle. The parlor’s geometry and furniture placement remain locked." + }, + { + "shot_id": 11, + "first_frame": "Same parlor. Over-the-shoulder angle from behind the 45-year-old man, looking toward the 42-year-old woman and 48-year-old woman across the tea table. The 45-year-old man’s shoulder and cup are in the foreground. The 42-year-old woman holds a cup at chest height; the 48-year-old woman mirrors her with a cup. The 25-year-old woman stands slightly behind them, smiling. The cake stand and tea service are centered and steady.", + "video_prompt": "Over-the-shoulder medium shot on a 65mm lens from behind the 45-year-old man. Over 5–6 seconds, the 42-year-old woman raises her cup slightly, initiating a toast; the 48-year-old woman and 25-year-old woman lift their cups in response. The 45-year-old man’s cup rises into the frame. They hold the toast moment briefly without clinking aggressively, maintaining a gentle family-friendly mood. Background remains structurally unchanged." + }, + { + "shot_id": 12, + "first_frame": "Same parlor. Camera now near the portraits on the right wall, aiming diagonally toward the fireplace and tea table. The group begins to reposition for a keepsake photo: the 45-year-old man steps behind the tea table toward the settee; the 48-year-old woman moves to sit on the settee; the 42-year-old woman guides the 25-year-old woman toward the armchairs. Their bodies pass behind the armchairs causing clear occlusions. The settee and armchairs remain fixed.", + "video_prompt": "Eye-level medium-wide shot on a 30mm lens from the right wall near the portrait gallery. Over 7 seconds, the 48-year-old woman sits on the settee (cushion compression visible), the 45-year-old man stands just behind the settee backrest, and the 42-year-old woman gently motions the 25-year-old woman to stand near the armchair. The 25-year-old woman steps behind an armchair, briefly occluded, then reappears in her spot. The room’s layout remains perfectly consistent." + }, + { + "shot_id": 13, + "first_frame": "Same parlor. Camera is placed near the fireplace facing outward toward the settee and group, as if from the mantel area. A vintage wooden tripod camera (period-appropriate, non-branded) is set near the tea table aimed at the settee. The 42-year-old woman leans forward near the tripod camera, adjusting the shutter timer lever. The 45-year-old man and 48-year-old woman are in position by the settee; the 25-year-old woman stands nearby with hands clasped. Warm chandelier light glows; everything else remains fixed.", + "video_prompt": "Eye-level medium shot on a 45mm lens from near the fireplace, framing the tripod camera and the group beyond it. Over 6 seconds, the 42-year-old woman carefully adjusts the camera timer and then steps backward, moving around the tea table without bumping it. She retreats toward the group, briefly passing behind the tripod legs (occlusion), then clears the frame to join the others. Background geometry remains unchanged." + }, + { + "shot_id": 14, + "first_frame": "Same parlor. Camera now faces the settee straight-on from the tea table area. The four adults are posed for the photo: the 48-year-old woman seated on the settee center, the 42-year-old woman seated on the settee edge or perched modestly beside it, the 25-year-old woman standing to the left near an armchair, and the 45-year-old man standing behind them with relaxed posture. The music box remains open on the mantel in the background, and the mirror and clock are unchanged. Everyone smiles softly, hands calm and modestly placed.", + "video_prompt": "Eye-level medium-wide shot on a 35mm lens from the tea table side, symmetrical framing with the settee centered. Over 5–8 seconds, the group holds still for the timed photograph; a subtle, brief change in brightness suggests the camera’s shutter capture. After the moment, they relax slightly—small shoulder drops and gentle laughter—while maintaining their positions. The Victorian parlor’s architecture and furniture remain perfectly stable and consistent to the end." + } + ] + } + ], + "metadata": { + "theme_key": "family_gathering_victorian_mansion", + "theme_description": "A family gathering in a Victorian mansion", + "consistency_type": "Type B", + "requested_scenes": 3, + "requested_shots": 14 + } +} diff --git a/vimax_benchmark/fashion_model_runway_styles_typeA.json b/vimax_benchmark/fashion_model_runway_styles_typeA.json new file mode 100644 index 0000000..3c80ba5 --- /dev/null +++ b/vimax_benchmark/fashion_model_runway_styles_typeA.json @@ -0,0 +1,113 @@ +{ + "story_overview": "A single adult fashion model prepares for a cultural fashion showcase and steps through dramatically different environments, presenting modest outfits inspired by multiple traditions. The show builds from rehearsal to a high-stakes final runway moment where the model unites the styles into one respectful, modern look.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Backstage dressing room, warm vanity bulbs reflecting in a large mirror. The main model is a 28-year-old East Asian woman with a sleek chin-length black bob, a small beauty mark under her right eye, and calm brown eyes. She wears the same signature outfit for the entire story: a modest ivory high-neck long-sleeve blouse, a tailored teal blazer, high-waisted charcoal wide-leg trousers, and clean white leather sneakers; simple silver hoop earrings and a thin silver watch. She stands centered, hands resting lightly on a garment rack, makeup brushes and folded fabric swatches on the counter. Soft, warm lighting, tidy professional mood.", + "video_prompt": "Eye-level medium shot, 50mm lens. The model takes a slow breath, checks her reflection, then lifts a small clipboard from the counter and nods with quiet confidence. Vanity lights glow steadily; slight camera push-in to emphasize her face and beauty mark." + }, + { + "shot_id": 2, + "first_frame": "Same model and outfit, now seen from a higher angle in the dressing room. A mood board on the wall shows abstract patterns and color palettes (no readable brand text). A steaming mug and neatly folded scarves lie on the counter. The model is three-quarters turned to camera, holding a folded paper fan as a prop at waist height. Warm tungsten lighting with gentle shadows.", + "video_prompt": "High-angle medium-wide shot, 35mm lens. The model opens the paper fan once with a smooth wrist motion, then closes it and places it on the counter. She glances toward the doorway as if hearing a cue, then steps one pace forward; camera tilts down slightly to follow her hands and the fan." + }, + { + "shot_id": 3, + "first_frame": "Hard cut to a bright outdoor plaza in midday sun with pale stone tiles and a modern fountain in the background. The same model and identical outfit stands near the fountain’s edge; water arcs sparkle behind her. She holds a lightweight patterned shawl (draped over her forearms, not worn) with geometric motifs inspired by global textiles. Crisp sunlight, high contrast, airy mood.", + "video_prompt": "Wide shot, low angle, 24mm lens. The model walks two measured steps along the fountain rim (at a safe distance), gently lifting the shawl to display its pattern like a banner, then lowers it. The camera tracks sideways, keeping her centered while water glints and wind lightly flutters the shawl." + }, + { + "shot_id": 4, + "first_frame": "Hard cut to a neon-lit night street market scene with lantern-like lights and colorful stalls blurred behind. The same model and outfit stands under a canopy of lights; reflections shimmer on wet pavement. She holds a small woven tote bag with a simple handle. Cool-magenta and teal lighting, cinematic bokeh.", + "video_prompt": "Eye-level close-up, 85mm lens. The model raises the woven tote to chest height, turns it slightly to show texture, then lowers it while offering a warm, professional smile. The camera performs a gentle rack focus from the tote weave to her eyes and beauty mark, then back to her hands." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Hard cut to a serene tea house interior with wood beams, paper screens, and a low table. The same model and identical outfit kneels on a floor cushion (posture upright, modest), hands resting on her thighs. A ceramic tea set sits neatly on the table. Soft diffused daylight through screens, calm atmosphere.", + "video_prompt": "Static eye-level medium shot, 50mm lens. The model reaches forward to lift a teacup with both hands, pauses to present it respectfully, then sets it back down gently. Subtle camera push-in as her hands move, emphasizing careful contact with the cup and table." + }, + { + "shot_id": 6, + "first_frame": "Hard cut to a sunlit desert at golden hour with rippled dunes and long shadows. The same model and outfit stands on firm sand, holding a lightweight scarf in both hands, arms extended slightly outward. Warm amber light, wind lifting small grains of sand; sky gradient from gold to blue.", + "video_prompt": "Wide shot, eye-level, 28mm lens. The model turns slowly in place a quarter turn as wind billows the scarf; she then brings the scarf closer, letting it ripple without covering her face. The camera arcs around her to the right, keeping her outfit and facial features consistent in changing light." + }, + { + "shot_id": 7, + "first_frame": "Hard cut to a lush tropical garden walkway after rain, glossy leaves and orchids in the background. The same model and identical outfit stands beneath a wooden pergola; droplets cling to foliage. She holds a floral lei-like garland in her hands (not worn), smiling softly. Soft overcast light, fresh green tones.", + "video_prompt": "Medium-wide shot, slightly high angle, 35mm lens. The model steps forward along the walkway, gently lifting the garland to show its flowers, then lowers it to waist height. The camera tracks backward smoothly; foreground leaves briefly sweep across frame edges to test depth and parallax." + }, + { + "shot_id": 8, + "first_frame": "Hard cut to a snowy mountain overlook with pine trees and a wooden railing. The same model and identical outfit stands near the railing; her blazer and blouse remain unchanged despite the cold setting. She holds a thick patterned wool wrap folded over her forearms. Cool daylight, crisp air, visible breath kept subtle.", + "video_prompt": "Low angle medium shot, 40mm lens. The model lifts the folded wool wrap slightly to display the pattern, then rests it back on her forearms. She looks out toward the mountains and nods as if acknowledging the next segment. The camera pans gently left, revealing more of the snowy vista while keeping her centered." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Hard cut to a minimalist museum gallery with white walls and spotlit textile art panels (abstract, non-branded). The same model and identical outfit stands on polished concrete floor, holding a small booklet. Cool neutral lighting with clean shadows; quiet, curated mood.", + "video_prompt": "Eye-level medium shot, 50mm lens. The model opens the booklet, flips one page, then gestures with an open palm toward a textile panel as if presenting inspiration. The camera slides laterally a short distance, maintaining her face and outfit details while lights remain constant." + }, + { + "shot_id": 10, + "first_frame": "Hard cut to an urban rooftop at sunset with skyline silhouettes and string lights overhead. The same model and identical outfit stands beside a clothing rack holding colorful, modest garments on hangers (no logos). A small handheld steamer sits on a table. Warm backlight, gentle lens flare.", + "video_prompt": "Medium-wide shot, backlit, 35mm lens. The model rolls the clothing rack one step forward, stops it carefully, and smooths a hanger with her fingertips. The camera dollies in slightly from the side, catching sunset glow edging her blazer and hair." + }, + { + "shot_id": 11, + "first_frame": "Hard cut to a professional runway venue corridor with black curtains and bright stage spill light at the far end. The same model and identical outfit stands in three-quarter profile, holding a small headset mic pack in her hand (not worn). Crew silhouettes move softly out of focus. High-contrast lighting, anticipatory tension.", + "video_prompt": "Over-the-shoulder medium shot, 70mm lens. The model clips the mic pack onto a belt pouch at her waist (no exposure, clothing remains modest), then takes a steadying breath. The camera pushes forward toward the bright stage opening, framing her as she steps closer to the curtains." + }, + { + "shot_id": 12, + "first_frame": "Hard cut to the runway entrance, intense white stage lights and a glossy catwalk stretching forward. The same model and identical outfit stands at the threshold, holding a small rectangular presentation card with abstract symbols (not readable text). Audience is a soft blur of shapes and lights. Dramatic spotlight, high energy.", + "video_prompt": "Low angle full-body shot, 24mm lens. The model takes two deliberate runway steps forward, pauses, then raises the presentation card briefly at chest height before lowering it. The camera tilts up from her sneakers to her face, emphasizing her beauty mark and consistent styling under harsh lights." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Hard cut to a circular stage center with projection mapping on the floor showing shifting cultural pattern mosaics (abstract, respectful, non-specific). The same model and identical outfit stands centered, holding four small fabric swatches in different colors in one hand. Bright spotlight from above, surrounding darkness, vivid projected colors.", + "video_prompt": "Overhead top-down shot, 18mm lens. The model rotates slowly in place while extending the fabric swatches outward like a fan, letting projections wash across her blazer and trousers. The camera descends slightly (simulated crane move) to intensify the swirling patterns around her feet." + }, + { + "shot_id": 14, + "first_frame": "Hard cut to a side-of-runway angle with the catwalk diagonal across frame. The same model and identical outfit walks mid-runway; behind her, large LED screens display abstract motifs inspired by multiple weaving traditions. Her posture is poised; hands relaxed at sides. Bright key light with cool fill, crisp reflections on the glossy floor.", + "video_prompt": "Tracking side shot, 35mm lens. The model completes a short runway pass, then performs a single clean pivot at the end mark and begins walking back. The camera tracks parallel, keeping her identity locked while the LED patterns shift in the background." + }, + { + "shot_id": 15, + "first_frame": "Hard cut to a close frontal runway shot. The same model and identical outfit is near camera, holding the fabric swatches now stacked neatly. Spotlight highlights her face; catchlights in her eyes, beauty mark clearly visible. Background audience remains blurred, bokeh lights sparkling.", + "video_prompt": "Eye-level close-up, 85mm lens. The model stops, lifts the stacked swatches slightly, then lowers them and places her free hand over her heart in a respectful gesture. The camera performs a subtle push-in and micro rack focus from her hand to her eyes, emphasizing sincerity and culmination." + }, + { + "shot_id": 16, + "first_frame": "Hard cut to a bright backstage exit with daylight pouring through an open door to a quiet street outside. The same model and identical outfit stands in the doorway, holding the fabric swatches and clipboard together at her side. Confetti-like paper pieces (plain, no text) rest on the floor from the show. Soft natural light, relieved and peaceful mood.", + "video_prompt": "Medium-wide shot, eye-level, 35mm lens. The model takes a final step forward into the daylight, pauses, and looks back with a calm smile, then gently closes the door partway. The camera remains steady as the lighting shifts from stage glow to natural daylight, ending on her consistent silhouette." + } + ] + } + ], + "metadata": { + "theme_key": "fashion_model_runway_styles", + "theme_description": "A fashion model showcasing different cultural fashion styles", + "consistency_type": "Type A", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/fashion_shoot_art_gallery_typeB.json b/vimax_benchmark/fashion_shoot_art_gallery_typeB.json new file mode 100644 index 0000000..6d69eed --- /dev/null +++ b/vimax_benchmark/fashion_shoot_art_gallery_typeB.json @@ -0,0 +1,73 @@ +{ + "story_overview": "Inside a contemporary art gallery set up as a fashion photoshoot, a small adult crew iterates through poses and lighting setups, culminating in a clean hero shot with perfect alignment of model, artwork, and camera.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a contemporary indoor art gallery with frozen, consistent geometry: polished concrete floor; white walls; a large abstract canvas on the left wall; a tall glass display case centered mid-room; two matte-black structural columns near the center-right; a long pale-wood bench in the foreground right; a track-lighting grid on the ceiling; and a reception desk far back left. A fashion photoshoot is set: a black tripod camera with tether cable aimed toward the center, a large softbox on a rolling C-stand to the left of the camera, and a silver reflector propped near the bench. Three adults (all 20+): a model (adult woman, 28) in a modest, long-sleeve navy midi dress and ankle boots stands mid-room near the glass case; a photographer (adult man, 35) in a dark button-up and trousers stands behind the tripod; a stylist (adult woman, 32) in a beige blazer and slacks stands near the bench holding a clipboard. Bright neutral gallery lighting with soft shadows.", + "video_prompt": "Eye-level wide shot, 24mm lens. The photographer steps from left of the tripod to directly behind it, lightly adjusting the camera head; the model takes two small steps toward the glass display case and turns her shoulders toward the left-wall canvas; the stylist walks behind the bench in the foreground, briefly occluded by the bench backrest as she crosses to the reflector. Track lights remain fixed; the room layout does not change." + }, + { + "shot_id": 2, + "first_frame": "Medium shot from the photographer’s side, showing the tripod camera in the foreground left, the model mid-ground framed between the two matte-black columns, and the glass display case behind her. The left-wall abstract canvas remains visible. The softbox stands to camera-left; the reflector leans near the bench at frame right. The model’s expression is calm and professional, hands relaxed at waist height; the photographer’s posture is focused, one hand on the lens barrel.", + "video_prompt": "Medium shot, slight low angle, 50mm lens. The photographer rotates the lens focus ring and nods; the model shifts weight from left foot to right foot and subtly raises her chin; the stylist enters frame from right, then passes behind the nearer column, becoming partially occluded before reappearing to adjust the reflector angle. No background elements move; the gallery architecture stays perfectly consistent." + }, + { + "shot_id": 3, + "first_frame": "Overhead high-angle shot looking down onto the same gallery: the long bench is clearly anchored at the foreground right; the glass display case is centered; the two black columns stand near center-right; the tripod and softbox are positioned left-of-center. The stylist kneels beside the bench with the reflector resting against the bench leg; the model stands just in front of the glass case; the photographer stands by the tripod. Clean, even lighting from the track grid.", + "video_prompt": "Overhead static camera, 18mm lens. The stylist slides the reflector a few inches along the floor until it contacts the bench leg and then tilts it upward; the model takes one measured step backward so her heels align closer to the glass case base; the photographer leans in to check framing on the camera’s rear screen. Movements emphasize depth and contact with the bench and floor; the room remains fixed." + }, + { + "shot_id": 4, + "first_frame": "Close-up shot near the reception desk area at the back left, with the gallery’s fixed desk edge in the foreground and the tripod camera visible deeper in the room. A laptop sits on a small rolling cart (foreground) connected by a tether cable leading toward the tripod. The stylist (adult woman, 32) stands beside the cart, modest beige blazer sleeves rolled down; her finger hovers over the keyboard. Background still shows the left-wall abstract canvas and the glass display case.", + "video_prompt": "Eye-level close-up, 70mm lens. The stylist types briefly and drags a slider on the laptop screen, then looks up toward the model; the tether cable gently lifts and settles as she repositions the cart a few inches, wheels turning and stopping. The cart stays on the same floor plane, reinforcing the stable gallery geometry." + }, + { + "shot_id": 5, + "first_frame": "Two-shot medium view from behind the glass display case (the case edge and reflections are visible in the foreground), looking outward toward the model and photographer. The model stands slightly left of center; the photographer stands behind the tripod to the left. The nearer black column partially blocks the stylist who is walking behind it, creating clear occlusion. The bench remains foreground right, unchanged.", + "video_prompt": "Medium two-shot, eye-level, 35mm lens. The model extends one arm gently to rest her fingertips on the glass case corner (light contact), then retracts to a neutral pose; the photographer raises the camera slightly and clicks a burst (silent visual action); the stylist crosses from right to left behind the nearer column, disappearing for a moment before reappearing beside the softbox. Background remains locked with consistent reflections and fixed placements." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 6, + "first_frame": "Low-angle shot from near the bench’s front edge, emphasizing depth: the bench dominates foreground right; the reflector now rests leaning against the bench seat; the model stands mid-ground between the columns; the photographer and tripod are left-of-center; the softbox is slightly angled toward the model. The glass display case remains centered behind the model. Lighting is still neutral, with a slightly brighter key from the softbox direction.", + "video_prompt": "Low-angle wide shot, 28mm lens. The model steps forward from mid-ground toward the foreground, then stops just behind the bench line, maintaining modest posture; the photographer takes a half-step backward to keep framing; the stylist reaches in to tilt the softbox down by a few degrees, hands on the stand yoke, then releases it. All actions maintain solid contact with floor and equipment; the gallery’s layout remains unchanged." + }, + { + "shot_id": 7, + "first_frame": "Over-the-shoulder shot from behind the photographer, with his shoulder and camera body in the foreground left, pointed at the model. The model is framed with the left-wall abstract canvas as a backdrop; the glass display case is still visible to her right. The stylist stands near the softbox, checking the stand clamp. The black columns remain fixed, one cutting into the right side of the frame.", + "video_prompt": "Over-the-shoulder, eye-level, 85mm lens. The photographer subtly pans the camera a few degrees to re-center the model against the canvas; the model turns her torso slightly toward the lens and relaxes her shoulders; the stylist steps behind the right-side column, briefly occluded, then reappears to raise the reflector off the floor and hold it at waist height. The room stays perfectly stable." + }, + { + "shot_id": 8, + "first_frame": "Side profile medium shot of the model near the glass display case. The case is on her immediate right; she stands at a respectful distance, hands together at mid-torso. The stylist is in the background walking from the bench toward the model, passing behind the case so her legs become partially obscured by the case base. The photographer is farther left, visible near the tripod.", + "video_prompt": "Medium profile shot, 50mm lens. The stylist walks behind the glass display case, partially occluded by the case frame, and stops beside the model to adjust the model’s collar and sleeve cuff (modest wardrobe touch-up); the model holds still and nods; the photographer raises a hand to signal readiness. The fixed gallery structures and object positions remain constant." + }, + { + "shot_id": 9, + "first_frame": "Tight close-up of the softbox and light stand hardware at left-of-center, with the gallery’s track lights above and the black column edge in the background. The stylist’s hands (beige blazer sleeves) grip the stand knob; the photographer is blurred in the background near the tripod; the model is also blurred mid-room. No logos or brand marks are visible.", + "video_prompt": "Close-up, 100mm macro lens. The stylist loosens the knob, lowers the softbox a few inches, then tightens it; the softbox subtly swings and settles, showing realistic inertia. Background remains stable: the same ceiling track grid and column edge do not shift or warp." + }, + { + "shot_id": 10, + "first_frame": "Hero wide shot aligned for the final image: the model stands centered between the two matte-black columns with the abstract canvas to the left wall and the glass display case behind her; the bench anchors the foreground right; the tripod and photographer are just off-center left, slightly lowered as if giving the model the spotlight; the stylist stands near the reflector by the bench, out of the model’s direct line. Lighting is crisp and balanced, creating a polished editorial look while remaining natural and family-friendly.", + "video_prompt": "Eye-level wide shot, 24mm lens. The photographer steps aside from the tripod to clear the frame, then nods; the stylist gently angles the reflector toward the model and freezes; the model takes a final micro-adjustment—one foot slides a few centimeters, shoulders square, chin slightly lifted—and holds a confident, calm pose. The moment lands as the climax: the set is perfectly arranged, with strong depth cues and consistent gallery geometry across the entire shot." + } + ] + } + ], + "metadata": { + "theme_key": "fashion_shoot_art_gallery", + "theme_description": "A fashion photoshoot in an art gallery", + "consistency_type": "Type B", + "requested_scenes": 2, + "requested_shots": 10 + } +} diff --git a/vimax_benchmark/filmmaker_lighting_setups_typeA.json b/vimax_benchmark/filmmaker_lighting_setups_typeA.json new file mode 100644 index 0000000..b47a75f --- /dev/null +++ b/vimax_benchmark/filmmaker_lighting_setups_typeA.json @@ -0,0 +1,88 @@ +{ + "story_overview": "A meticulous adult filmmaker runs a lighting stress-test for a single short scene, moving through dramatically different locations and lighting sources to prove that consistent subject identity can hold up across natural and artificial setups, culminating in a controlled mixed-light final take.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "A 32-year-old filmmaker (adult), medium height with warm brown skin, short curly black hair, a neat goatee, and a small crescent-shaped scar on the left eyebrow. He wears the same outfit for the entire story: a charcoal-gray chore jacket over a light beige button-up shirt, dark indigo jeans, and matte black lace-up boots; a black canvas camera strap crosses his chest. He stands in a bright, sunlit meadow at golden hour, tall grasses glowing amber, distant tree line softly blurred. He holds a compact mirrorless camera in both hands and a small notepad clipped to a board. The composition is a wide establishing frame with the filmmaker centered, sun low behind him creating a rim light and long shadows.", + "video_prompt": "Eye-level wide shot, 24mm lens. He walks three steps forward through the grasses, raises the camera to eye level, and pans slightly to test exposure; the wind moves the grass and his jacket hem. Lens catches mild sun flare as he tilts the camera toward the backlight, then he checks the notepad with a focused, calm expression." + }, + { + "shot_id": 2, + "first_frame": "The same 32-year-old filmmaker in the exact same outfit and features stands on a rugged coastal cliff under harsh midday sun. The ocean below is deep blue with whitecaps; jagged rocks frame the foreground. He squints slightly from the brightness, holding the camera with a rectangular white bounce card clipped to his left forearm. Hard shadows fall under his brow and chin; the sky is clear and bright.", + "video_prompt": "Low angle medium shot, 35mm lens. He angles the bounce card to fill his face, then rotates his torso toward the sun to compare contrast; he taps the camera controls with his thumb. The camera (viewer) holds steady while he steps closer to the cliff edge, then stops safely, nodding as he observes the shadow change." + }, + { + "shot_id": 3, + "first_frame": "The same filmmaker, unchanged outfit and identity, stands in a misty evergreen forest at dawn. Cool blue-gray fog hangs between tall trunks; the ground is damp with moss and pine needles. Soft, diffuse light filters through branches. He has a small LED pocket light in his right hand (switched off), camera in his left, and a compact reflector folded at his side. The frame is a quiet medium-wide with vertical tree lines creating depth.", + "video_prompt": "Shoulder-height medium-wide shot, 28mm lens. He walks slowly between two trunks, stops, and turns his head to judge the ambient softness; he switches the pocket LED on briefly toward his face, then off, comparing. The camera subtly dollies right to reveal parallax in the trees as fog drifts gently." + }, + { + "shot_id": 4, + "first_frame": "The same filmmaker, unchanged, inside a dim industrial underpass at night. Concrete pillars recede into the background; a single sodium-vapor streetlight casts a warm orange pool of light, while distant cool bluish spill from a nearby sign adds contrast. He stands half in shadow, half in the orange light, holding his camera and a small handheld light meter. The composition is a moody medium shot with strong chiaroscuro.", + "video_prompt": "Eye-level medium shot, 50mm lens. He raises the light meter into the orange beam, then steps forward so his face crosses from shadow into warm light; he watches the meter reading and adjusts the camera dial. The camera makes a gentle push-in, emphasizing the warm/cool color split across his jacket and cheek." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "The same filmmaker, unchanged outfit and facial details, sits on a wooden park bench beneath a canopy of leaves with dappled sunlight flickering across him. Bright green highlights and moving shadow patches pattern his jacket and face. A compact collapsible diffuser is propped beside him; his camera rests in his lap. The shot is a side-profile medium frame with background bokeh of sunlit leaves.", + "video_prompt": "Side-on medium shot, 85mm lens. He lifts the diffuser overhead with one hand, reducing contrast on his face; the dappled shadows soften. He glances down to review the camera screen, then lowers the diffuser to compare, his expression analytical. The camera remains locked, letting the leaf shadows animate the scene." + }, + { + "shot_id": 6, + "first_frame": "The same filmmaker, unchanged, in a rainy city street at night under vivid neon signage. Wet pavement reflects magenta and cyan; raindrops bead on his charcoal jacket. He stands beneath an awning, holding the camera close to his chest and a small translucent umbrella tucked under his arm. The frame is a tight medium close-up with neon bokeh behind him and specular highlights on water droplets.", + "video_prompt": "Eye-level medium close-up, 70mm lens. He leans slightly forward, tilts his face into the neon glow to see color casts, and wipes a raindrop from the camera body with his sleeve. The camera performs a slow left-to-right slide, changing how magenta and cyan wrap across his cheekbones and scar." + }, + { + "shot_id": 7, + "first_frame": "The same filmmaker, unchanged, in a bright white cyc studio with controlled artificial lighting. Two softboxes on stands are visible at the edges, and a black flag on a C-stand cuts spill. The environment is clean and minimal; the floor is seamless white. He stands centered, holding his camera; a clapperboard rests on a stool nearby. Lighting is neutral, soft, and even.", + "video_prompt": "Front-facing medium shot, 35mm lens. He reaches to the left softbox and rotates it inward, then steps back to center to observe; his face transitions from flat to slightly more modeled. The camera gently tilts down and up (micro tilt) as he checks the histogram on the camera screen, then looks up with a satisfied nod." + }, + { + "shot_id": 8, + "first_frame": "The same filmmaker, unchanged outfit and identity, in a cozy home workshop lit by a warm table lamp and a cool computer monitor. Wooden shelves with tools and fabric rolls line the background; a small practical lamp with a linen shade glows amber on a desk. He stands at the desk, placing a small LED panel next to the lamp. The frame is an over-the-shoulder view showing his hands, the LED panel, and the camera on the desk.", + "video_prompt": "Over-the-shoulder medium shot, 40mm lens. He powers on the LED panel and adjusts brightness; the mixed lighting shifts on his hands and jacket sleeve. He slides a sheet of diffusion in front of the LED, then points the camera at a small color chart on the desk, making tiny exposure tweaks. The camera holds steady while the color temperature interplay becomes more apparent." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "The same filmmaker, unchanged, on a snow-covered hillside at late afternoon with bright sun reflecting off snow, creating strong bounce and high contrast. Evergreen trees dot the slope; the sky is crisp and pale. He wears the same outfit (no added clothing layers), standing firmly on packed snow, holding the camera and a gray card. The frame is a wide shot with intense highlights and sparkling snow texture.", + "video_prompt": "Eye-level wide shot, 24mm lens. He holds the gray card up to the camera, then lowers it and turns slightly as the sun glints; he shields his eyes briefly with his hand and adjusts exposure compensation. The camera makes a slow push-in while the glittering snow bounce brightens the underside of his jaw and jacket collar." + }, + { + "shot_id": 10, + "first_frame": "The same filmmaker, unchanged, inside a greenhouse at midday where sunbeams streak through glass panes, creating striped highlights across rows of plants. Humid air produces soft haze; water droplets cling to leaves. He stands in a narrow aisle between plant benches, camera held at chest height, a small battery lantern placed on the floor behind a pot to create an artificial fill. The composition is a dramatic medium-wide with leading lines of the benches.", + "video_prompt": "Low angle medium-wide shot, 28mm lens. He kneels carefully, repositions the lantern behind a leaf cluster to test motivated fill, then stands back up and frames a shot down the aisle. The camera tracks backward slightly as he steps forward, showing shifting sun stripes across his face and the persistent scar detail." + }, + { + "shot_id": 11, + "first_frame": "The same filmmaker, unchanged, in a dark theater stage environment with a single bright spotlight from above and faint haze in the air. Black curtains and rigging are visible; a tripod stands to one side. He stands in the spotlight circle, his face and jacket sharply lit while the background falls into deep shadow. The frame is a centered medium shot with strong top light emphasizing facial structure and casting a shadow under his brow and nose.", + "video_prompt": "High angle medium shot, 50mm lens. He steps half a pace forward within the spotlight, then raises a small white card under his chin to reduce harsh shadows; the contrast softens. He looks into the lens, then to the side toward an unseen lighting control, and gives a small confirming nod. The camera slowly arcs a few degrees, keeping him centered while the spotlight edge remains crisp." + }, + { + "shot_id": 12, + "first_frame": "The same filmmaker, unchanged outfit and identity, back in the white cyc studio but now arranged for a final mixed-light test: one softbox with a warm gel on camera-left, one cool LED panel on camera-right, and a practical lamp in the background for depth. A simple backdrop table holds a color chart and a small neutral prop (a matte ceramic mug). He stands at center holding a clapperboard in his left hand and the camera in his right, ready to roll. Lighting creates a deliberate warm-cool split on his face while keeping modest, clean visuals.", + "video_prompt": "Eye-level medium close-up, 55mm lens. He snaps the clapperboard once, sets it on the table, then lifts the camera to eye level and holds steady for a final exposure check. He makes one precise dial adjustment, breathes out, and gives a subtle satisfied smile as the warm and cool lights balance across his features. The camera performs a short, smooth push-in to end on his focused eyes and consistent scar detail." + } + ] + } + ], + "metadata": { + "theme_key": "filmmaker_lighting_setups", + "theme_description": "A filmmaker testing different natural and artificial lighting setups", + "consistency_type": "Type A", + "requested_scenes": 3, + "requested_shots": 12 + } +} diff --git a/vimax_benchmark/gardener_four_seasons_typeA.json b/vimax_benchmark/gardener_four_seasons_typeA.json new file mode 100644 index 0000000..01915e9 --- /dev/null +++ b/vimax_benchmark/gardener_four_seasons_typeA.json @@ -0,0 +1,88 @@ +{ + "story_overview": "An adult gardener tends the same community plot through spring, summer, autumn, and winter, adapting to changing weather and light until a final winter reveal shows the garden’s year-long effort preserved and ready for the next cycle.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Spring morning in a community garden plot. Center frame: a 32-year-old gardener named Rowan, adult, medium height and build, warm brown skin, short curly black hair, neat trimmed beard, small crescent-shaped scar on the left eyebrow. He wears the same consistent outfit: forest-green canvas jacket with brass buttons, beige work pants, brown leather work boots, and tan gardening gloves; a small wooden-handled trowel is tucked into his right jacket pocket. He kneels beside a raised bed of dark, freshly turned soil with tiny green sprouts. Soft golden sunrise light, dewy leaves, pastel sky, shallow depth of field with a blurred fence and budding trees behind him.", + "video_prompt": "Eye-level medium shot with a 50mm lens. Rowan gently presses soil around a sprout with gloved fingers, then looks up and smiles slightly at the bed. A light breeze stirs his jacket hem and nearby seedlings; the camera makes a slow, subtle push-in to emphasize his focused expression and the scar on his left eyebrow. Warm spring birdsong mood, soft sunlight glinting on dew." + }, + { + "shot_id": 2, + "first_frame": "Bright spring midday on a different location: a tidy greenhouse interior with translucent panels and condensation beads. Rowan (same exact face, scar, hair, beard, and same outfit) stands near a potting bench crowded with seed trays and labeled sticks. Overhead diffused light creates soft shadows; a coil of hose hangs on the wall; a metal watering can sits on the bench. The environment is distinctly indoors, humid, and reflective.", + "video_prompt": "High angle wide shot with a 24mm lens. Rowan slides a seed tray forward, sprinkles soil from a scoop, and lightly mist-sprays the tray. The camera tilts down and pans slightly to follow his hands as droplets sparkle in the diffused greenhouse light. Condensation subtly streaks on the panels; the watering can remains stationary as a visual anchor." + }, + { + "shot_id": 3, + "first_frame": "Spring rainy late afternoon in an open field allotment far from the greenhouse. Heavy clouds, wet earth, and a shallow puddle reflecting gray sky. Rowan (same identity and same outfit) stands under a large, plain umbrella held in his left hand; his right hand steadies a wooden stake next to young plants. Raindrops streak diagonally; cool bluish light; distant hills blurred by mist.", + "video_prompt": "Low angle medium-wide shot with a 35mm lens. Rowan pushes the stake into the mud, then ties a soft garden twine around the plant support. The umbrella trembles slightly under rain; water drips from its edge. The camera holds steady while rain motion and Rowan’s careful tying provide action; subtle rack focus from his gloved hands to his face, keeping the eyebrow scar visible." + }, + { + "shot_id": 4, + "first_frame": "Spring golden hour at a riverside footbridge near the garden, dramatically different setting. Rowan (same exact outfit and features) leans on the bridge railing, holding a small paper packet of seeds in both gloved hands. The river below reflects orange sunlight; willow branches sway; cyclists pass in the distant background as silhouettes. Warm, cinematic backlight outlines his jacket.", + "video_prompt": "Over-the-shoulder medium shot with a 70mm lens. From behind Rowan’s right shoulder, he opens the seed packet and checks the label, then pockets it. The camera slowly tracks left along the railing, keeping Rowan framed against the glowing river. Background silhouettes drift past; lens flare blooms briefly as he turns his head, revealing the consistent scar and beard in profile." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Summer noon in a sun-baked rooftop garden. Heat shimmer above gravel, bright harsh sunlight, strong shadows. Rowan (same identity and same outfit) stands beside large planters overflowing with leafy greens and small tomatoes. A city skyline rises behind him; a white reflective wall bounces light. He holds a watering wand connected to a hose, poised over a planter.", + "video_prompt": "Eye-level wide shot with a 28mm lens. Rowan turns the watering wand on; a fan of water arcs into the planter, droplets sparkling in the harsh sun. He steps sideways along the planters, watering in a steady rhythm. The camera makes a slow lateral dolly to match his movement; heat shimmer dances in the background while the skyline stays sharp." + }, + { + "shot_id": 6, + "first_frame": "Summer late afternoon in a shaded orchard path, entirely different from the rooftop. Dappled sunlight filters through thick leaves. Rowan (same exact features and outfit) crouches near a low branch, examining a leaf with small holes. A wooden basket sits on the ground beside him. The mood is calm but concerned; soft green light wraps around him.", + "video_prompt": "Close-up with an 85mm lens at eye level. The camera starts tight on Rowan’s gloved hands holding the leaf, then racks focus to his face as he narrows his eyes thoughtfully. He gently turns the leaf, then nods and places it back. The camera remains steady; dappled light shifts across his jacket and the eyebrow scar as leaves sway overhead." + }, + { + "shot_id": 7, + "first_frame": "Summer night at an outdoor community garden worktable under string lights. The setting is different again: dark sky, warm bulbs, moths fluttering. Rowan (same identity and same outfit) sits on a wooden bench, leaning over a notebook with simple garden sketches and dates. A thermos and a few seed packets lie nearby. Warm, cozy lighting with deep shadows.", + "video_prompt": "Top-down overhead shot with a 35mm lens. Rowan writes a short note, taps the page with his pen, and flips to a new page with a chart. The camera slowly rotates a few degrees clockwise for visual energy while staying overhead. String lights flicker subtly; moths drift through the frame edges; his tan gloves and green jacket remain consistent and clearly visible." + }, + { + "shot_id": 8, + "first_frame": "Autumn early morning in a foggy pumpkin patch, dramatically different from the summer night table. Pale fog, orange pumpkins scattered, wet grass. Rowan (same exact face, scar, beard, hair, and same outfit) stands with a small hand pruner in his right glove, inspecting a vine. Cool, muted light; breath faintly visible in the chill.", + "video_prompt": "Medium shot at eye level with a 50mm lens. Rowan trims a dried vine carefully with the pruner, then gently lifts a pumpkin to check its stem. The camera performs a slow push-in through the fog, increasing intimacy. Fog curls around his boots; a soft exhale briefly clouds near his face, emphasizing the seasonal shift." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Autumn midday at a bustling farmer’s market, a new environment. Colorful stalls and crates of produce, people in modest clothing moving in the background. Rowan (same identity and same outfit) stands at a stall with apples and jars, holding a reusable cloth bag. Neutral daylight with crisp contrast; banners flutter overhead.", + "video_prompt": "Handheld-style medium-wide shot with a 35mm lens at eye level. Rowan selects a few apples and places them into the cloth bag, then exchanges a friendly nod with an unseen vendor. The camera makes slight natural sway, then reframes to keep Rowan centered as a crowd passes behind him. Fabric textures—canvas jacket, cloth bag—remain detailed and consistent." + }, + { + "shot_id": 10, + "first_frame": "Late autumn sunset on a windswept hillside garden plot. Tall dry grasses, copper sky, leaves spinning in gusts. Rowan (same exact features and same outfit) struggles to hold a lightweight row cover (white fabric) over a raised bed frame. The mood is tense but hopeful; dramatic side light outlines him.", + "video_prompt": "Low angle wide shot with a 24mm lens. A gust catches the row cover; Rowan steps forward, grips the fabric with both gloved hands, and pulls it down to align it over the frame. The camera tracks a half-step forward as he anchors one corner, emphasizing effort without violence. Leaves whip past the lens; sunset light flares along the jacket’s brass buttons." + }, + { + "shot_id": 11, + "first_frame": "Winter twilight during first snowfall in a lantern-lit courtyard garden, new setting. Snowflakes drift down; stone paths and bare trellises. Rowan (same identity and same outfit) kneels beside a low bed, placing a thick layer of straw mulch around dormant plants. A metal lantern on the ground casts warm light on his gloves and face; cool blue ambient light surrounds.", + "video_prompt": "Knee-level close shot with a 50mm lens, slightly low angle. Rowan spreads straw mulch in a careful ring, pats it down, then pauses to brush snow off the bed’s edge. The camera slowly pans from the lantern to his face, catching the consistent eyebrow scar and calm determination. Snow accumulates lightly on his shoulders and jacket sleeves." + }, + { + "shot_id": 12, + "first_frame": "Winter dawn after snowfall at the original community garden plot, now quiet and white. Raised beds are neatly covered; a small handmade sign reads “Spring Starts Here.” Rowan (same exact face, scar, beard, hair, and same outfit) stands centered, holding a simple wooden crate with neatly folded garden cloths and a sealed jar labeled “Saved Seeds.” Pale pink sunrise reflects on snow; crisp, serene atmosphere.", + "video_prompt": "Wide establishing shot with a 35mm lens at eye level. Rowan walks a few steps forward in the snow, sets the crate gently on the edge of a raised bed, and straightens the sign so it faces the camera. The camera remains steady, then performs a slow, subtle zoom-in as he looks across the beds with a quiet satisfied expression—climax of preparedness and continuity. Snow sparkles in the sunrise; the scene ends on the garden sign and Rowan’s steady presence." + } + ] + } + ], + "metadata": { + "theme_key": "gardener_four_seasons", + "theme_description": "A gardener working through the four seasons", + "consistency_type": "Type A", + "requested_scenes": 3, + "requested_shots": 12 + } +} diff --git a/vimax_benchmark/investigation_antique_bookshop_typeB.json b/vimax_benchmark/investigation_antique_bookshop_typeB.json new file mode 100644 index 0000000..3a84fac --- /dev/null +++ b/vimax_benchmark/investigation_antique_bookshop_typeB.json @@ -0,0 +1,73 @@ +{ + "story_overview": "Inside an antique bookshop, two adult staff members investigate a subtle mystery: a rare ledger appears to be missing. Their careful search through the shop’s fixed interior reveals a hidden compartment behind a rolling ladder, culminating in the discovery of the ledger and a clue that explains the misplacement without any wrongdoing.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "A richly detailed antique bookshop interior, fixed layout: a long mahogany checkout counter on the right foreground with a brass desk lamp and receipt book; a narrow central aisle of creaking wooden floorboards leading to the back wall; towering floor-to-ceiling bookshelves on both left and right; a rolling wooden ladder on a brass rail attached to the left bookshelf; a round reading table with two high-back chairs mid-left; a carved wooden pillar near center-left; a stained-glass transom window above the back doorway; a small globe on a side cabinet near the back-right. Warm amber light from hanging pendant lamps and dust motes in the air. Two adults: Bookshop owner (woman, 42, medium height, brown skin, hair in a neat bun, wearing a forest-green cardigan over a cream blouse and dark slacks) stands behind the counter, looking concerned at an open inventory ledger; assistant (man, 35, tall, light skin, short dark hair, wearing a navy sweater over a collared shirt and khaki trousers) stands in the central aisle holding a modest flashlight pointed down at the floorboards.", + "video_prompt": "Eye-level wide shot from near the front door, 24mm lens, static camera. The owner taps a page in the open ledger and gestures toward the back shelves; the assistant nods and takes two careful steps down the aisle, sweeping the flashlight beam low across the floorboards while dust floats in the warm light. No changes to the room layout; all furniture and architecture remain fixed." + }, + { + "shot_id": 2, + "first_frame": "Same fixed bookshop geometry and placement. Closer view on the right side: the mahogany counter dominates foreground right; brass desk lamp casts a warm pool of light; the owner’s hands hover over a handwritten inventory page with an empty line highlighted by a small paper marker. Behind her, shelves remain aligned; the central aisle and pillar are visible in the midground.", + "video_prompt": "Eye-level medium close-up, 50mm lens, slight handheld steadiness. The owner slides the paper marker to the missing entry, then closes the ledger gently and points toward the left bookshelf and ladder, indicating where the rare book should be. Light flickers subtly from the lamp; background remains structurally identical." + }, + { + "shot_id": 3, + "first_frame": "Same bookshop layout. View angled toward the left bookshelf: the rolling ladder on its brass rail is mid-left; the carved pillar is center-left foreground, partially blocking the aisle. The assistant is mid-aisle, now closer to the ladder, his flashlight angled upward toward book spines. The reading table and chairs sit to the left, unchanged.", + "video_prompt": "Low angle wide shot, 28mm lens, slow lateral pan left-to-right. The assistant walks behind the carved pillar (briefly occluded), emerging on the other side to reach for the ladder rung. He raises the flashlight to scan titles; the ladder and shelves do not warp or shift." + }, + { + "shot_id": 4, + "first_frame": "Same fixed interior. Tight view on the ladder and left shelves: the assistant’s hands grip the ladder’s side rails; brass rail hardware is visible along the top. Rows of aged leather-bound books line the shelves; tiny title labels and worn gilding are visible. The central aisle remains in the background with the back doorway and stained-glass transom.", + "video_prompt": "Eye-level medium shot, 35mm lens, static camera. The assistant rolls the ladder smoothly along the brass rail from left to right by pushing with his hands; the wheels track along the floor with a soft creak. He pauses, leans in to examine a gap in the shelf, and adjusts one book. The room’s geometry remains perfectly consistent." + }, + { + "shot_id": 5, + "first_frame": "Same bookshop layout. The owner is now out from behind the counter, standing near the reading table mid-left; the assistant is at the left shelves. A small step stool sits near the table (unchanged position relative to table). The owner holds a folded note card and a pencil. Warm pendant light pools on the table surface, showing a few stacked books and a magnifying glass.", + "video_prompt": "Eye-level two-shot, 40mm lens, slow push-in. The owner walks from the reading table toward the central aisle, stopping beside the assistant; she unfolds the note card and points to a penciled shelf code while the assistant listens and nods. Their movement respects depth: they pass in front of the reading table and remain clearly separated from the fixed shelves and pillar." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 6, + "first_frame": "Same fixed interior. View toward the back of the shop: the back doorway sits under the stained-glass transom; to the back-right is a small side cabinet with a globe on top; the central aisle leads straight to it. The assistant kneels near the baseboard along the back-right shelves, flashlight beam grazing the lower shelf edge. The owner stands a few steps behind him in the aisle, holding the note card.", + "video_prompt": "High angle wide shot, 24mm lens, static camera from near the ceiling corner. The assistant scoots forward on his knees and peers under the lowest shelf, moving the flashlight beam along the baseboard; the owner leans slightly to see, then steps closer. The cabinet, globe, doorway, and shelves remain locked in place." + }, + { + "shot_id": 7, + "first_frame": "Same fixed layout. Close on the back-right side cabinet and adjacent shelf: the globe sits on the cabinet top; a small framed shop sign rests beside it. The assistant’s hand reaches behind the globe toward a subtle seam in the cabinet’s side panel. The owner’s torso is partially visible in the background, out of focus, standing in the aisle.", + "video_prompt": "Eye-level close-up, 85mm lens, shallow depth of field. The assistant carefully rotates the globe a few degrees, revealing a hidden latch; he presses it, and a narrow cabinet side panel pops open slightly. The motion is gentle and non-violent; the cabinet’s structure remains stable and consistent." + }, + { + "shot_id": 8, + "first_frame": "Same fixed interior. Medium view with strong depth: the assistant crouches at the open cabinet compartment back-right; the owner kneels beside him, both facing the compartment. Inside, a leather-bound ledger with a faded spine label is visible. The central aisle extends forward; the carved pillar and reading table remain in their exact positions in the midground-left.", + "video_prompt": "Eye-level medium shot, 35mm lens, slow tilt down to the compartment. The assistant reaches in and slides the leather-bound ledger out with both hands, then places it carefully on the cabinet top. The owner steadies the compartment door with one hand. Their bodies partially occlude the cabinet and shelf edges, emphasizing solidity; background stays unchanged." + }, + { + "shot_id": 9, + "first_frame": "Same fixed layout. Over-the-shoulder angle from behind the owner: her shoulder and green cardigan frame the left foreground; the ledger lies open on the cabinet top beside the globe. The assistant, facing the pages, points to a handwritten entry. Warm light catches the paper texture and ink. The back doorway and stained glass are still visible above.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens, subtle handheld micro-movement. The assistant turns one page slowly, then taps a margin note that matches the shelf code on the owner’s card; the owner nods and relaxes her posture. The page-turn is the main motion; the cabinet, globe, and doorway remain fixed." + }, + { + "shot_id": 10, + "first_frame": "Same fixed interior, wider composition re-establishing the whole shop: counter right foreground with brass lamp; central aisle; left ladder; reading table; carved pillar; back doorway and stained glass; cabinet and globe back-right. The owner stands behind the counter again, placing the recovered ledger into a protective cloth wrap. The assistant stands in the aisle holding the note card, now smiling with relief. Lighting remains warm and calm, dust motes drifting.", + "video_prompt": "Eye-level wide shot, 24mm lens, slow dolly backward toward the front door for a gentle concluding reveal. The owner ties the cloth wrap neatly and sets the ledger in a labeled drawer behind the counter; the assistant walks a few steps toward the counter and pins the note card onto a small corkboard beside the lamp. They exchange a satisfied nod, resolving the mystery. The room’s 3D structure and all furniture placements remain perfectly consistent to the end." + } + ] + } + ], + "metadata": { + "theme_key": "investigation_antique_bookshop", + "theme_description": "A mysterious investigation in an antique bookshop", + "consistency_type": "Type B", + "requested_scenes": 2, + "requested_shots": 10 + } +} diff --git a/vimax_benchmark/meditation_class_zen_temple_typeB.json b/vimax_benchmark/meditation_class_zen_temple_typeB.json new file mode 100644 index 0000000..5fad9d7 --- /dev/null +++ b/vimax_benchmark/meditation_class_zen_temple_typeB.json @@ -0,0 +1,98 @@ +{ + "story_overview": "Inside a serene zen temple meditation hall, three adult participants arrive and settle into a guided session. The class progresses from preparation to focused breathing, culminating in a resonant bell and a calm group bow—while the indoor 3D environment remains perfectly consistent as participants move through it.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide, symmetrical establishing view of a zen temple meditation hall interior (indoor 3D environment). Polished honey-toned wooden floorboards run horizontally across frame. A raised tatami platform occupies the center, bordered by a thin dark wood trim. At the back wall: a simple altar niche with a small bronze singing bowl on a low shelf, a single framed calligraphy panel above it, and a shallow vase with a green branch. Left side: two thick cedar pillars support a beam; a low wooden bench sits between pillar and wall. Right side: a latticed shoji-style window wall with soft daylight; a rolled reed mat stack neatly arranged near the right-front corner. In the foreground, a row of evenly spaced dark charcoal meditation cushions (zafu) with matching mats (zabuton). No people yet. Soft morning light, visible wood grain, tranquil mood.", + "video_prompt": "Eye-level wide shot, 24mm lens, locked-off tripod. The room remains perfectly still; subtle dust motes drift in the sunlight near the shoji window. A faint shift in light intensity suggests passing clouds, but the geometry and furniture placement remain unchanged." + }, + { + "shot_id": 2, + "first_frame": "Medium-wide shot from near the right-front corner, looking diagonally toward the center platform and left pillars. The same fixed room layout: tatami platform center, altar niche back wall, bench left, cushions foreground. An adult instructor (approx. 40, calm expression) in modest dark-gray robe stands near the left pillar, holding a small wooden clipboard at waist height. Two adult students (20+), in modest long-sleeve tops and loose pants, enter from the back-left walkway behind the bench, partially occluded by the left pillar.", + "video_prompt": "Medium-wide, 35mm lens, slight right-to-left pan. The two students walk behind the left pillar and bench (occlusion clearly visible), then emerge into the open floor area, slowing respectfully. The instructor turns their head gently to acknowledge them, keeping posture relaxed. Background architecture and object positions remain unchanged." + }, + { + "shot_id": 3, + "first_frame": "Low angle medium shot focused on the foreground cushions: three zafu aligned. The tatami platform edge and wood trim are visible behind them. One adult student (20+, modest cream sweater, dark loose trousers) kneels beside a cushion, hands placed on the mat, preparing to sit. Another adult student (20+, muted blue cardigan over a plain shirt, dark trousers) stands just behind, waiting, seen from mid-thigh down. The shoji window light falls from the right, making soft highlights on the floorboards.", + "video_prompt": "Low angle medium shot, 40mm lens, locked-off. The kneeling student slides the cushion a few centimeters to align it with the mat (contact with floor; slight fabric compression). The standing student steps forward carefully, feet crossing from background to midground, then stops, maintaining respectful distance. Environment remains fixed and undistorted." + }, + { + "shot_id": 4, + "first_frame": "Over-the-shoulder shot from behind the instructor at left, looking toward the center platform and the row of cushions in the foreground. The instructor’s shoulder and sleeve edge frame the left side. Three adult participants (instructor plus two students) are now inside the hall: the students move toward cushions, one passing behind the center platform corner. The altar niche and calligraphy remain centered in the far background.", + "video_prompt": "Over-the-shoulder, 50mm lens, gentle dolly-in a few inches. The students walk from midground to foreground, one briefly disappearing behind the raised tatami platform corner (occlusion and depth), then reappearing to kneel at a cushion. The instructor lowers the clipboard slightly, preparing to begin. All room elements stay spatially consistent." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Top-down overhead shot centered on the tatami platform and surrounding floor. The platform’s woven texture is clear; the dark wood border forms a crisp rectangle. Three cushions form a neat line below the platform. The instructor stands at the front edge of the platform, feet together on the wood floor. The two students kneel on their mats, backs straight, hands resting on thighs. Left pillars and right shoji window edges are visible as framing cues.", + "video_prompt": "Overhead static shot, 20mm lens, locked-off. The instructor steps onto the tatami platform (visible foot placement and slight tatami compression), then turns to face the seated students. The students shift from kneeling to seated posture on their cushions, settling slowly with minimal movement. Background geometry remains unchanged." + }, + { + "shot_id": 6, + "first_frame": "Eye-level medium close-up of the instructor seated cross-legged on the front-center of the tatami platform, hands resting in lap. Behind them, the altar niche is softly in focus, with the bronze singing bowl on the shelf. The instructor’s robe is modest and neatly folded. Soft daylight from the right shoji window creates gentle rim light along the instructor’s shoulder.", + "video_prompt": "Eye-level medium close-up, 70mm lens, locked-off. The instructor inhales and exhales slowly, then raises one hand to demonstrate a relaxed hand mudra, returning it to the lap. Subtle head nod as if offering guidance. The altar, bowl, and all room features remain fixed." + }, + { + "shot_id": 7, + "first_frame": "Side-profile two-shot at floor level: the two adult students sit on their cushions in the foreground row, facing left toward the platform (off-frame). The nearer student in cream sweater sits upright; the second in muted blue cardigan sits one cushion behind and slightly to the right. The wooden floorboards lead toward the left pillars; the bench is visible in the far left background. Shoji window light from right creates soft gradients on their clothing.", + "video_prompt": "Side-profile medium shot, 55mm lens, slow lateral slider move right-to-left. Both students perform synchronized breathing: shoulders rise subtly on inhale, settle on exhale; hands remain still on thighs. The camera move reveals slight parallax against the fixed pillars and bench, confirming stable room geometry." + }, + { + "shot_id": 8, + "first_frame": "Medium shot from the back of the room looking forward: the altar niche and calligraphy are centered at the far wall; the tatami platform occupies midground with the instructor seated. The two students are in the foreground, seen from behind, seated on cushions. A narrow walkway runs along the left side behind the bench. Lighting remains soft and even, morning calm.", + "video_prompt": "Eye-level medium-wide shot, 28mm lens, locked-off. The instructor gently gestures with an open palm as if cueing attention to breath. One student subtly adjusts posture—straightening spine and rolling shoulders back—then becomes still. No objects shift; the room remains spatially identical." + }, + { + "shot_id": 9, + "first_frame": "Close shot on the bronze singing bowl in the altar niche: the bowl sits on a small cushion on the shelf; a wooden striker rests beside it. The shelf wood grain is visible; the calligraphy frame edge appears above. The instructor’s robe sleeve enters frame from the left, hand approaching the striker.", + "video_prompt": "Close-up, 85mm lens, locked-off. The instructor’s hand lifts the striker (contact and lift clearly shown), pauses above the bowl, then lowers it gently to touch the rim without striking yet. The bowl and niche remain fixed; lighting stays constant with soft highlights on bronze." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Low angle shot near the floor, looking toward the tatami platform corner. The instructor stands from seated position at the platform’s front, robe draping modestly. The students remain seated on cushions in the foreground, facing forward. The platform edge and wood trim create strong lines; the left pillar is visible, anchoring the consistent geometry.", + "video_prompt": "Low angle medium-wide, 32mm lens, locked-off. The instructor rises smoothly to standing (robe folds shift naturally), steps to the platform edge, and turns slightly toward the altar niche. The students remain still, emphasizing the instructor’s motion against the stable set." + }, + { + "shot_id": 11, + "first_frame": "Over-the-shoulder shot from behind the instructor facing the altar niche. The instructor’s shoulder frames the left foreground; the bronze singing bowl is centered midframe on the shelf. The striker is held in the instructor’s right hand. The shoji window light from the right side adds a bright strip of highlight on the bowl rim.", + "video_prompt": "Over-the-shoulder, 60mm lens, gentle push-in. The instructor makes a single, controlled strike: the striker moves forward, contacts the bowl rim, then withdraws. The bowl remains stationary; only a subtle vibration shimmer in the highlight suggests resonance. The room stays perfectly consistent." + }, + { + "shot_id": 12, + "first_frame": "Reaction two-shot from the front of the seated students, facing them with the platform behind (instructor visible in soft focus). The students’ faces are calm, eyes softly lowered. Their modest clothing remains unchanged. The cushions and mats are neatly aligned; floor reflections are faint and stable.", + "video_prompt": "Eye-level medium two-shot, 50mm lens, locked-off. The students complete a slow exhale in unison, then bring hands together briefly at chest level in a respectful gesture before returning hands to thighs. The instructor remains in the background, still. No background elements move." + }, + { + "shot_id": 13, + "first_frame": "Wide shot from the left side of the hall, capturing the left pillars in the foreground and the group aligned: instructor near the platform, students on cushions. One student is partially occluded by the nearer pillar as the camera angle places the pillar between viewer and student. The bench remains against the left wall; the shoji windows line the right wall unchanged.", + "video_prompt": "Wide shot, 26mm lens, slow dolly rightward. As the camera moves, the occluded student gradually reveals from behind the pillar (clear occlusion/parallax). The instructor steps down from the platform to the wood floor (visible contact), approaching the students calmly to signal closing. Geometry and furniture placement remain constant." + }, + { + "shot_id": 14, + "first_frame": "Symmetrical front wide shot mirroring the opening composition: centered tatami platform, altar niche and calligraphy at back, cushions in foreground. All three adults are now kneeling in a line on their mats facing the altar: instructor in the middle, two students on either side. Soft morning light continues through the shoji windows; the room looks undisturbed and perfectly consistent.", + "video_prompt": "Eye-level wide shot, 24mm lens, locked-off. The trio performs a synchronized closing bow: hands slide forward to the mat (contact), torsos incline, pause, then return upright. They remain still for a final breath as the light holds steady. End on calm, stable tableau with no changes to the environment." + } + ] + } + ], + "metadata": { + "theme_key": "meditation_class_zen_temple", + "theme_description": "A meditation class in a zen temple interior", + "consistency_type": "Type B", + "requested_scenes": 3, + "requested_shots": 14 + } +} diff --git a/vimax_benchmark/mentor_student_craft_learning_typeC.json b/vimax_benchmark/mentor_student_craft_learning_typeC.json new file mode 100644 index 0000000..122137b --- /dev/null +++ b/vimax_benchmark/mentor_student_craft_learning_typeC.json @@ -0,0 +1,93 @@ +{ + "story_overview": "In a calm community workshop, an adult mentor teaches an adult student the basics of woodworking. They practice measuring, cutting, and assembling a small wooden keepsake box together, overcoming a mistake and finishing the craft as a team.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide two-shot inside a bright community makerspace workshop. Character A (mentor): a 45-year-old woman, medium height, warm brown skin, short curly black hair, thin rectangular glasses, wearing a forest-green work jacket over a light gray crewneck shirt, dark denim jeans, and brown leather work boots; she has a calm, focused expression. Character B (student): a 26-year-old man, tall and slim, light skin, short sandy-blond hair, clean-shaven, wearing a navy-blue button-up work shirt, tan canvas work pants, and gray sneakers; he looks attentive. They stand at a large maple workbench with a neatly arranged measuring tape, pencil, try square, wood glue bottle, two bar clamps, and four pre-cut pine boards. Sunlight from high windows creates soft highlights; the background shows pegboards with tools and a safety poster.", + "video_prompt": "Eye-level wide shot, 24mm lens. The mentor gestures to the tools laid out, then points to a simple sketch of a small keepsake box on a clipboard. The student nods and leans slightly forward to see. Subtle handheld stability, gentle workshop ambiance, soft daylight." + }, + { + "shot_id": 2, + "first_frame": "Over-the-shoulder medium shot from behind the mentor (Character A) looking down at the clipboard on the workbench. Her forest-green jacket sleeve and thin glasses frame the view. Character B’s hands rest near the pencil and try square. The sketch shows dimensions for a small rectangular box. The wood grain of the maple bench is crisp; sunlight stripes cross the paper.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens. The mentor taps the dimension lines with the pencil, then slides the measuring tape toward the student. The student’s hands move in to take the tape carefully. The camera holds steady with a slight push-in as the exchange happens." + }, + { + "shot_id": 3, + "first_frame": "Close-up two-shot at the workbench edge focusing on both characters’ hands: Character A’s hands (sleeves of forest-green jacket) guide the try square; Character B’s hands (navy-blue cuff visible) hold a pencil. A pine board lies flat with a faint mark line starting. The background is softly blurred tools on pegboard.", + "video_prompt": "Table-level close-up, 85mm lens. The mentor aligns the try square and demonstrates a clean right-angle mark; the student follows, drawing a straight pencil line. The mentor nods approvingly; their hands stay clearly separate, with no clothing attribute mixing. Soft daylight glints on the metal square." + }, + { + "shot_id": 4, + "first_frame": "Medium two-shot from the side of the bench. Character A stands on the left, Character B on the right. A compact benchtop miter saw sits further down the bench with its blade guard down. Both wear clear safety glasses (in addition to Character A’s thin rectangular glasses beneath). The mentor points to the saw’s safety features while the student watches closely, hands off the tool.", + "video_prompt": "Eye-level medium two-shot, 35mm lens. The mentor demonstrates the safe stance and indicates the clamp point without powering the saw. The student mirrors her posture and repeats the steps with empty hands. The camera makes a small lateral move to keep both faces visible, emphasizing instruction and attention." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Wide shot of the workbench and miter saw area. Character B positions a pine board against the saw fence; Character A stands slightly behind and to his left, supervising. Bar clamps and offcuts are neatly to the side. Bright, even workshop lighting; no harsh shadows.", + "video_prompt": "Wide shot, 24mm lens. The student secures the board and performs a careful cut motion (tool implied as operating safely with guard; no visible debris). The mentor watches the alignment and signals a stop with a small hand gesture. The camera remains steady to show both characters and the tool in frame." + }, + { + "shot_id": 6, + "first_frame": "Reaction two-shot, medium close-up. Character B holds two cut pieces up at chest height; one looks slightly longer than the other. He frowns mildly, confused but calm. Character A leans in with a reassuring expression, her forest-green jacket collar visible, glasses catching light.", + "video_prompt": "Eye-level medium close-up two-shot, 50mm lens. The student compares the pieces side-by-side, then lowers them. The mentor gently points to the measurement mark on one piece and nods as if explaining the mismatch. The student exhales, then nods in understanding. Subtle push-in to emphasize the learning moment." + }, + { + "shot_id": 7, + "first_frame": "Over-the-shoulder shot from behind the student (Character B). His navy-blue shoulders frame the view as Character A uses the measuring tape on the pine board. The tape hook sits at the board edge; her finger indicates the correct measurement. The bench surface shows pencil shavings and a neatly placed eraser.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens. The mentor re-measures slowly, then marks a new line with the pencil. The student’s hand enters to hold the board steady, keeping his navy cuff clearly distinct from her green sleeve. The camera stays locked with crisp focus on the tape and mark." + }, + { + "shot_id": 8, + "first_frame": "Close-up on sanding action at the bench: a rectangular sanding block in Character B’s hands (navy cuffs visible) smooths a pine edge. Character A’s hand (green sleeve) steadies the piece from the far side. Fine wood dust is minimal and controlled; a small dust brush and shop vacuum hose are nearby.", + "video_prompt": "Low table-level close-up, 85mm lens. The student sands with short, even strokes; the mentor adjusts the piece’s angle slightly to keep it flat. The camera tracks a few inches along the edge to reveal the smoother surface, with soft highlights on the wood grain." + }, + { + "shot_id": 9, + "first_frame": "Medium two-shot at the center of the workbench. The four pine boards are arranged into a rectangle, and a small bottle of wood glue stands upright. Character A holds the glue bottle; Character B holds a small brush. Both look focused, standing shoulder-to-shoulder with clear personal attire separation (green jacket vs navy shirt).", + "video_prompt": "Eye-level medium two-shot, 35mm lens. The mentor applies a thin bead of glue along an edge; the student spreads it evenly with the brush. They then bring two boards together carefully. The camera makes a slight arc move to show the joint closing cleanly." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Wide shot of the assembly stage. The forming box sits on the bench with two bar clamps partially tightened. Character A on the left adjusts one clamp; Character B on the right holds the try square against a corner to check for a right angle. Warm overhead lights mix with daylight, creating a cozy, determined mood.", + "video_prompt": "Wide shot, 24mm lens. The mentor tightens the clamp gradually while the student checks the corner and signals with a thumbs-up when it aligns. The camera dollies in slightly to emphasize teamwork and precision." + }, + { + "shot_id": 11, + "first_frame": "Tight close-up of the box corner and try square. The metal square sits flush against the pine corner; a tiny line of glue squeeze-out is visible and being wiped with a clean cloth held by Character A’s green-sleeved hand. Character B’s navy cuff points at the corner, confirming alignment.", + "video_prompt": "Macro-style close-up, 100mm lens. The mentor wipes away excess glue in one smooth motion; the student taps the square lightly to confirm the corner is true. The camera holds focus on the crisp 90-degree junction and the wood texture." + }, + { + "shot_id": 12, + "first_frame": "Medium two-shot reveal at the bench. The clamps are now removed and placed aside. The small keepsake box sits centered, lightly sanded and clean. Character B looks relieved and proud; Character A smiles gently, hands folded. A simple water-based finish tin and a clean brush sit nearby, unopened.", + "video_prompt": "Eye-level medium two-shot, 50mm lens. The student carefully rotates the box a quarter turn to show the even joints. The mentor nods and gestures toward the smooth edges. The camera makes a slow, subtle push-in as their expressions shift from concentration to satisfaction." + }, + { + "shot_id": 13, + "first_frame": "Wide final two-shot in the makerspace. Character A and Character B stand side-by-side behind the workbench with the finished keepsake box on a small felt pad. They exchange a brief, respectful handshake. The workshop background remains tidy: pegboard tools, safety poster, and soft sunlight through high windows.", + "video_prompt": "Eye-level wide shot, 28mm lens. The mentor and student shake hands once, then both look down at the box and nod appreciatively. The camera holds steady, then performs a gentle pull-back to end on the completed craft and the calm, supportive atmosphere." + } + ] + } + ], + "metadata": { + "theme_key": "mentor_student_craft_learning", + "theme_description": "A mentor and student learning a craft together", + "consistency_type": "Type C", + "requested_scenes": 3, + "requested_shots": 13 + } +} diff --git a/vimax_benchmark/music_recording_home_studio_typeB.json b/vimax_benchmark/music_recording_home_studio_typeB.json new file mode 100644 index 0000000..cfae2ac --- /dev/null +++ b/vimax_benchmark/music_recording_home_studio_typeB.json @@ -0,0 +1,98 @@ +{ + "story_overview": "In a cozy home recording studio, two adult musicians collaborate to capture the perfect take. They set up microphones and levels, record multiple passes while moving through the room’s fixed layout, then achieve a polished final performance and celebrate the successful session.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "A detailed view of a compact home recording studio with fixed geometry: a rectangular room with warm acoustic foam panels on the walls, a large window on the left wall with closed beige blinds, and a thick patterned rug centered on the floor. In the exact center sits a sturdy wooden desk facing the back wall, holding a computer monitor with a DAW timeline visible, a small MIDI keyboard, an audio interface with knobs, and two black studio monitors on isolation pads. A boom microphone stand with a silver condenser mic and circular black pop filter stands just in front of the desk, slightly right of center. A second straight mic stand is positioned near the right wall. A tall bookshelf filled with binders and music books stands on the back-right corner. Two adult musicians (both 20+): Person A (an adult woman, late 20s, medium height, brown skin, dark curly hair tied back, wearing a modest forest-green sweater and dark jeans) stands near the doorway at front-left. Person B (an adult man, early 30s, tall, light-to-medium skin, short dark hair, neatly trimmed beard, wearing a modest navy button-up shirt and tan chinos) stands near the desk. Warm, soft ceiling light and subtle monitor glow; tidy cables run along the baseboards.", + "video_prompt": "Eye-level wide shot from the front-left corner, 24mm lens. Person A walks from the doorway toward the center rug, stepping around the boom mic stand without touching it; Person B gestures toward the computer monitor and audio interface. Both glance at the fixed desk setup and mic placement; the camera remains locked off while their movement tests depth and occlusion around the mic stands and desk edges." + }, + { + "shot_id": 2, + "first_frame": "Same studio layout unchanged: desk centered facing back wall, boom mic stand with pop filter slightly right of center, straight mic stand near right wall, bookshelf back-right, window with blinds left. Person B is seated in the desk chair, hands near the audio interface knobs; Person A stands behind the boom mic stand, partially occluded by the pop filter ring. The DAW timeline on the monitor shows multiple tracks. Lighting remains warm with a slight screen glow.", + "video_prompt": "Over-the-shoulder medium shot from behind Person B’s right shoulder, 50mm lens aimed at the monitor and audio interface. Person B turns a gain knob and taps a key on the MIDI keyboard; Person A leans in from behind the pop filter to adjust the mic height, carefully sliding the boom clutch. Person A’s face moves in and out behind the pop filter, emphasizing occlusion and depth while the background geometry stays perfectly stable." + }, + { + "shot_id": 3, + "first_frame": "Same fixed room: desk, monitors, audio interface, boom mic, straight mic stand near right wall, bookshelf, window blinds, rug. Person A kneels on the rug near the desk’s left side, reaching toward a neatly coiled cable that runs along the baseboard; Person B stands near the right wall by the straight mic stand, holding a pair of closed-back headphones. The pop filter remains centered in front of the condenser mic.", + "video_prompt": "Low-angle medium-wide shot from near the rug’s front edge, 28mm lens. Person A slides the cable coil closer to the desk and tucks it along the baseboard, then stands up, moving from foreground to midground. Person B steps behind the straight mic stand, briefly occluded by it, and places the headphones on the desk corner. No furniture shifts; only the characters and cable move." + }, + { + "shot_id": 4, + "first_frame": "Same unchanging studio: desk centered, DAW visible, boom mic with pop filter, straight mic stand right, bookshelf back-right, window left with blinds, patterned rug. Person A now stands at the boom mic position, hands relaxed at her sides, looking toward the monitor. Person B leans slightly against the desk edge, one hand on the mouse. The room feels ready for recording; warm light and soft shadows.", + "video_prompt": "Side-profile medium shot from the right side of the room, 35mm lens, framing Person A at the boom mic and Person B at the desk. Person B clicks the mouse and nods; Person A inhales and does a short, quiet warm-up gesture (mouth shapes as if humming) while maintaining a modest, professional posture. Person A’s body subtly shifts closer to the pop filter, stopping at a consistent speaking distance; the desk and mic stands remain rigidly fixed." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Same fixed home studio layout. The boom mic with pop filter is center-right in front of the desk. Person A stands behind the pop filter wearing over-ear headphones now, facing the mic; Person B sits in the desk chair, one hand on the keyboard and the other near the audio interface. The DAW shows armed tracks and a visible recording area. Warm overhead light with slightly dimmed ambience, monitor glow more pronounced.", + "video_prompt": "Eye-level tight medium shot from directly in front of Person A and the mic, 85mm lens compressing the background. Person A begins a focused performance: subtle head movement and steady posture, hands down. Person B is visible softly in the background through the gap beside the pop filter, reaching forward to press record. The action emphasizes depth: Person A sharp in foreground, desk and Person B behind, all room geometry unchanged." + }, + { + "shot_id": 6, + "first_frame": "Same studio geometry unchanged. View toward the desk: monitor with DAW waveforms starting to appear, audio interface lights on, studio monitors fixed. Person B is seated close to the desk, leaning forward; Person A is visible at the mic position in the left background, partially blocked by the boom stand and pop filter.", + "video_prompt": "Eye-level close-up on the audio interface and Person B’s hands, 60mm lens. Person B carefully turns a knob a few degrees and watches the meters; his other hand hovers over the keyboard for a quick marker. In the background, Person A remains steady at the mic, her silhouette intermittently occluded by the boom arm. The camera is locked; only hands and subtle body shifts move." + }, + { + "shot_id": 7, + "first_frame": "Same room fixed: bookshelf back-right, straight mic stand near right wall, desk centered. Person A has stepped away from the mic and now stands near the bookshelf, holding a lyric sheet (plain paper). Person B stands up from the desk chair and walks toward the center rug. The boom mic remains in place at center-right.", + "video_prompt": "Wide shot from the back wall looking toward the desk, 24mm lens. Person B walks from the desk across the rug toward Person A; as he crosses, he passes behind the boom mic stand, briefly occluded by the pop filter and stand. Person A points to a line on the paper; they confer with calm expressions. No objects shift; the bookshelf and stands remain perfectly aligned." + }, + { + "shot_id": 8, + "first_frame": "Same unchanging studio. Person A sits on a simple armless chair positioned on the rug’s left side (chair stays fixed), holding the lyric sheet in her lap. Person B kneels near the center of the rug, reaching toward a small footswitch on the floor (a compact rectangular device) placed in front of the desk. The boom mic and pop filter remain center-right.", + "video_prompt": "Overhead medium-wide shot from above the center of the room, 26mm lens. Person B slides the footswitch a few inches closer to Person A’s standing path, then taps it once to test; Person A nods and rises from the chair, moving from seated to standing while the chair stays put. The overhead angle emphasizes the frozen floor plan: rug pattern, desk footprint, and stand positions remain constant." + }, + { + "shot_id": 9, + "first_frame": "Same fixed studio layout. Person A returns to the boom mic position, headphones on, standing squarely behind the pop filter. Person B sits at the desk, but now slightly turned to the left to see both the monitor and Person A. The DAW shows a previous take waveform and a new empty lane ready to record.", + "video_prompt": "Eye-level two-shot from the left side of the desk, 35mm lens, framing Person B in the foreground and Person A at the mic in the midground. Person B raises one finger as a silent count-in, then presses a key to start recording. Person A begins again with steadier rhythm, leaning in slightly and then returning to neutral distance from the pop filter. The action focuses on coordinated timing while the studio geometry remains unchanged." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Same room fixed: desk with monitor and speakers, boom mic center-right, straight mic stand right wall, bookshelf back-right, window left with blinds, rug centered. Person B has moved away from the desk and stands near the right wall by the straight mic stand, holding a small handheld shaker (simple percussion). Person A remains at the boom mic, headphones on, ready. Lighting slightly brighter, as if they’re energized for the final take.", + "video_prompt": "Eye-level medium-wide shot from the front-right corner, 28mm lens. Person B walks from the right wall toward the desk, passing behind the boom mic stand so the stand briefly occludes his torso. He stops at a marked spot on the rug (implied by his positioning) and lifts the shaker. Person A watches him briefly, then refocuses on the mic. Background elements do not warp or move." + }, + { + "shot_id": 11, + "first_frame": "Same fixed studio. A close view of Person A at the condenser mic: pop filter centered, boom arm visible, desk and monitor blurred behind. Person A’s expression is concentrated and calm; her posture is upright, hands relaxed. The headphones’ cable trails down toward the floor along the same path as before.", + "video_prompt": "Eye-level close-up, 85mm lens on Person A’s face and the pop filter. Person A performs the final take with controlled breath and subtle head motion; she leans in slightly on an emphasized phrase and eases back to consistent distance. The pop filter remains stationary; the camera holds steady to highlight clean performance without any background changes." + }, + { + "shot_id": 12, + "first_frame": "Same studio geometry. Person B stands mid-rug between the desk and right wall, holding the shaker at chest height. The straight mic stand is behind him near the right wall; the boom mic is visible left of him. Person A is visible in the background at the mic, partially obscured by the pop filter.", + "video_prompt": "Low-angle medium shot from near the floor at the rug’s front edge, 35mm lens. Person B plays the shaker with small rhythmic wrist movements, stepping one pace forward then back, testing depth as he moves closer to and farther from camera. The boom mic stand intermittently occludes Person A in the background. All studio furniture, stands, and bookshelf remain fixed and consistent." + }, + { + "shot_id": 13, + "first_frame": "Same unchanging room. Person B is back at the desk chair, leaning forward with one hand on the mouse and the other on the keyboard. Person A stands just behind the desk’s left side, having stepped away from the mic, looking at the monitor. The DAW shows multiple stacked waveforms, indicating the final layered take.", + "video_prompt": "Over-the-shoulder medium shot from behind Person A’s left shoulder toward the monitor, 40mm lens. Person B drags a region slightly on the timeline, then clicks play; both listen intently, heads tilting in sync. Person A points once at the screen, then relaxes her hand. The monitor glow subtly pulses with playback meters; the desk, speakers, and mic stands remain perfectly stable." + }, + { + "shot_id": 14, + "first_frame": "Same fixed studio layout for the conclusion: desk centered, boom mic and pop filter center-right, straight mic stand right wall, bookshelf back-right, window with blinds left, rug centered. Person A and Person B stand side-by-side on the rug in front of the desk, both smiling gently and relaxed. Person B holds the headphones loosely in one hand; Person A holds the lyric sheet folded neatly. Warm overhead light gives a cozy, finished-session mood.", + "video_prompt": "Eye-level wide shot from the front-center, 24mm lens. Person B sets the headphones carefully on the desk corner; Person A places the folded paper on the desk near the keyboard. They share a brief celebratory nod and a small, professional handshake, then both look toward the monitor as if satisfied with the final result. The camera remains locked; the room’s geometry and object placement stay unchanged through the final beat." + } + ] + } + ], + "metadata": { + "theme_key": "music_recording_home_studio", + "theme_description": "A music recording session in a home studio", + "consistency_type": "Type B", + "requested_scenes": 3, + "requested_shots": 14 + } +} diff --git a/vimax_benchmark/musician_cultural_venues_typeA.json b/vimax_benchmark/musician_cultural_venues_typeA.json new file mode 100644 index 0000000..adfaefb --- /dev/null +++ b/vimax_benchmark/musician_cultural_venues_typeA.json @@ -0,0 +1,113 @@ +{ + "story_overview": "A single adult musician travels the world performing the same uplifting melody in dramatically different cultural venues, building from a quiet street set to a climactic global finale where the audience joins in.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Golden-hour city plaza with warm stone paving and a small fountain in the background. Center frame: a 32-year-old man (adult) with olive skin, short curly black hair, a neatly trimmed beard, and a small crescent-shaped scar above his right eyebrow. He wears a modest outfit that must remain identical in every shot: navy wool blazer with brass buttons, light gray crew-neck sweater, dark denim jeans, and clean white low-top sneakers. He holds a natural-finish acoustic guitar with a red woven shoulder strap. A compact black gig bag lies closed at his feet; a small open case sits nearby with a few coins. Mood: calm, hopeful; soft rim light on his blazer texture.", + "video_prompt": "Eye-level medium-wide shot, 28mm lens. He takes a steady breath, adjusts the red woven strap, and begins a gentle fingerstyle pattern on the acoustic guitar. A few passersby slow down; the fountain sparkles behind him as the camera makes a subtle push-in and then holds, ending on his focused expression." + }, + { + "shot_id": 2, + "first_frame": "Same musician and identical outfit and guitar. New angle: tight close-up on his hands over the guitar’s sound hole, showing wood grain and the red strap crossing the lower corner of frame. Background bokeh of plaza lights and pedestrians; warm sunset highlights on his knuckles.", + "video_prompt": "Close-up, 85mm lens, shallow depth of field. His right hand alternates thumb and finger plucks while his left hand shifts to a higher chord shape; the strings visibly vibrate. Camera performs a slow lateral slide to reveal the red woven strap and the edge of the navy blazer sleeve, emphasizing consistency as the rhythm becomes more confident." + }, + { + "shot_id": 3, + "first_frame": "Same musician, identical outfit and guitar. New setting: a quiet indoor rehearsal room with pale acoustic panels, a metronome on a small table, and a wall clock. Cool, neutral overhead lighting creates soft shadows under his jawline; he sits on a simple wooden chair, posture upright, guitar resting on his thigh.", + "video_prompt": "High-angle medium shot, 35mm lens. He taps his sneaker lightly in time, nods, and practices a brighter variation of the melody. The metronome’s arm swings steadily; the camera gently tilts down to include the tapping foot and then tilts back up to his concentrated face." + }, + { + "shot_id": 4, + "first_frame": "Same musician, identical outfit and guitar. New setting: a nighttime airport terminal window wall overlooking runway lights and parked planes. Cool blue and white fluorescent lighting; reflections on polished floor. He stands near a row of seats, guitar held close, looking out the window with a determined, peaceful expression.", + "video_prompt": "Side-profile medium shot, 50mm lens. He walks slowly past the seats, pauses at the window, and softly strums a quiet chord as if promising the next performance. Camera tracks parallel for a few steps, then stops as he turns his head toward the runway lights, ending with his scar and beard clearly visible in the cool glow." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Same musician, identical outfit and guitar. New setting: a rain-dampened neon-lit alley market with colorful paper lanterns and small food stalls (no readable brand signage). Wet cobblestones reflect magenta and cyan light. He stands under a simple awning; light rain falls beyond the shelter.", + "video_prompt": "Low-angle wide shot, 24mm lens. He begins the melody with a stronger strum pattern; raindrops ripple in puddles at the bottom of frame. Camera makes a slow dolly-in from near the puddle reflections up to him, emphasizing the neon colors washing across his navy blazer while he stays steady and composed." + }, + { + "shot_id": 6, + "first_frame": "Same musician, identical outfit and guitar. New setting: a tranquil Japanese-style garden courtyard with raked gravel, stepping stones, and a wooden pavilion. Soft morning mist and diffused light; a small koi pond sits to one side. He is seated on the pavilion’s wooden edge, guitar balanced neatly.", + "video_prompt": "Overhead-to-oblique medium shot, 35mm lens. He plays a delicate, airy variation; his fingers move precisely. The camera slowly cranes down and slightly forward, revealing raked gravel patterns and the pond’s gentle ripples, ending on his calm expression as the melody breathes with the mist." + }, + { + "shot_id": 7, + "first_frame": "Same musician, identical outfit and guitar. New setting: a sunlit desert oasis courtyard with clay walls, patterned tiles, and a shade canopy. Warm, high-contrast sunlight creates sharp shadows; a small water basin glints behind him. He stands centered, feet planted, guitar strap taut.", + "video_prompt": "Eye-level medium shot, 50mm lens. He leans into a rhythmic strum and adds a percussive tap on the guitar body. Camera makes a quick, controlled push-in and then stabilizes, capturing the crisp texture of the blazer and the red strap against the bright desert palette as the tempo lifts." + }, + { + "shot_id": 8, + "first_frame": "Same musician, identical outfit and guitar. New setting: a bustling European-style covered market hall with iron beams, stained glass, and produce stalls. Midday light filters through colored glass, casting soft patches of color on the floor. He stands near a central aisle, a small semicircle of adults watching at a respectful distance.", + "video_prompt": "Handheld-feel medium-wide shot, 28mm lens. He plays the melody louder; a few listeners nod and clap lightly on the beat (family-friendly, no chanting text). The camera drifts slightly left-right to mimic a documentary feel, then settles into a steadier frame as the crowd’s attention gathers." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Same musician, identical outfit and guitar. New setting: a candlelit stone chapel interior (no specific religious symbols in close detail), with wooden pews and tall arched windows. Warm amber candlelight flickers across stone textures; the air looks slightly hazy for atmosphere. He stands in the aisle, respectful and serene.", + "video_prompt": "Centered wide shot, 24mm lens. He plays softly; the notes feel reverent. Camera performs a slow, symmetrical push-in down the aisle toward him while candle flames flicker, ending on a medium framing where his facial scar and beard remain clearly defined in warm light." + }, + { + "shot_id": 10, + "first_frame": "Same musician, identical outfit and guitar. New setting: a bright coastal amphitheater with white stone seating, turquoise sea visible beyond, and flags fluttering (no national identifiers). Strong sunlight and sea breeze; his blazer moves slightly in the wind. He stands on the stage area, facing empty seats that begin to fill with a few adults arriving.", + "video_prompt": "Wide shot, 20mm lens, slight high angle from the seating. He strikes a bold opening chord and continues confidently. Camera pans slowly as a few audience members take seats, then returns to center on him as the wind lifts the red strap subtly, highlighting motion against the ocean backdrop." + }, + { + "shot_id": 11, + "first_frame": "Same musician, identical outfit and guitar. New setting: a snowy mountain overlook with a wooden railing and pine trees coated in snow. Cold, bright daylight; his breath is visible. He wears the same outfit without additions; the navy blazer contrasts sharply with the white snow.", + "video_prompt": "Low-angle medium shot, 35mm lens. He plays with energetic strums, stamping his sneaker once to keep time. The camera arcs slightly around him from left to right, snow crystals sparkling in the air; his breath puffs with each phrase as the melody swells toward a peak." + }, + { + "shot_id": 12, + "first_frame": "Same musician, identical outfit and guitar. New setting: a tropical beach at sunset with palm silhouettes, gentle waves, and warm orange-pink sky. He stands on firm sand near a small driftwood log, facing a small group of adult listeners seated at a respectful distance. Soft backlight outlines his blazer and hair curls.", + "video_prompt": "Backlit medium-wide shot, 28mm lens. He transitions into the chorus-like section, strumming in a steady, uplifting rhythm. The camera slowly dolly-in while waves roll in the background; a couple of listeners clap softly on the off-beat, and the shot ends as he smiles briefly without breaking the tempo." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Same musician, identical outfit and guitar. New setting: a modern glass-walled cultural center lobby with international-style decor elements (abstract textiles, sculptures) and bright, clean lighting. A small portable stage platform sits center; a modest microphone stand is present but he plays acoustically. Adults gather in a semicircle, attentive.", + "video_prompt": "Eye-level wide shot, 24mm lens. He steps onto the platform and begins the final full arrangement, confident and joyful. Camera makes a smooth gimbal push-in as the audience leans forward slightly, ending with him centered and framed by geometric glass reflections." + }, + { + "shot_id": 14, + "first_frame": "Same musician, identical outfit and guitar. New setting: rooftop at night with a panoramic city skyline of many buildings and soft bokeh lights. String lights are hung overhead, creating warm pools of light; a gentle breeze moves them. He stands near the rooftop edge railing (safe distance), guitar ready, expression focused.", + "video_prompt": "Over-the-shoulder shot from behind him, 35mm lens. He strums strongly; the skyline twinkles ahead. Camera slowly rises a few inches and tilts down to catch the guitar neck and his left hand moving through the chord changes, then tilts back up to the lights as the music builds toward the climax." + }, + { + "shot_id": 15, + "first_frame": "Same musician, identical outfit and guitar. New setting: a grand indoor concert hall with wooden acoustic panels and soft spotlights. The stage is modest and uncluttered; a seated audience of adults fills the lower frame edges in silhouette. A bright key spotlight illuminates his face, making the eyebrow scar crisp.", + "video_prompt": "Front-facing medium close-up, 70mm lens. He reaches the emotional high point of the melody, intensifying strums and adding a clean percussive tap. The camera holds steady with a subtle micro push-in; audience silhouettes gently sway, and the spotlight glints on the guitar’s natural wood as he sustains the climax." + }, + { + "shot_id": 16, + "first_frame": "Same musician, identical outfit and guitar. New setting: an outdoor world-fair-style courtyard at dusk with diverse architectural pavilions in the far background (generic, non-branded). Festoon lights glow overhead; a mixed group of adults stands in a wide circle. He is center frame, slightly angled, finishing the last phrase.", + "video_prompt": "Crane-down wide shot, 24mm lens. As he plays the final refrain, the camera descends gently from above to a level framing, revealing the circle of listeners. On the last measures, several audience members clap in unison; he ends with a clean final chord, lifts his gaze with a grateful smile, and the shot holds for a beat on the shared, uplifting moment." + } + ] + } + ], + "metadata": { + "theme_key": "musician_cultural_venues", + "theme_description": "A musician performing in different cultural venues around the world", + "consistency_type": "Type A", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/musicians_band_rehearsal_typeC.json b/vimax_benchmark/musicians_band_rehearsal_typeC.json new file mode 100644 index 0000000..52ef18c --- /dev/null +++ b/vimax_benchmark/musicians_band_rehearsal_typeC.json @@ -0,0 +1,93 @@ +{ + "story_overview": "Three adult musicians rehearse in a community arts studio for an evening showcase, refining timing and dynamics, overcoming a brief coordination hiccup, and ending with a confident final run-through.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a tidy community arts rehearsal studio with light gray acoustic panels, warm wooden floor, coiled cables neatly along the wall, a black music stand center, a compact keyboard on a stand to the left, a cajón box drum to the right, and a small amplifier near the back wall. Three adults (all 20+): Character A is a tall woman (late 20s) with medium-brown skin, curly black hair in a high puff, wearing a forest-green cardigan over a white crew-neck blouse, dark straight-leg jeans, and white sneakers; she holds a natural-finish acoustic guitar with a brown strap. Character B is a shorter man (early 30s) with light skin, neatly trimmed dark beard, short dark hair, wearing a navy button-up shirt, tan chinos, and brown shoes; he stands behind a compact keyboard, hands hovering above the keys. Character C is a medium-height woman (mid 30s) with East Asian features, straight chestnut hair in a low ponytail, wearing a burgundy sweater, black slacks, and black flats; she sits upright on the cajón with relaxed shoulders. Soft afternoon light enters from a high window, creating gentle shadows.", + "video_prompt": "Eye-level wide shot, 24mm lens. The camera slowly dollies forward 1 meter toward the trio as they exchange friendly nods; Character A adjusts her guitar strap, Character B taps a metronome app on a phone placed on the keyboard stand, and Character C lightly pats the cajón surface to test tone. Ambient room tone and a calm, focused mood." + }, + { + "shot_id": 2, + "first_frame": "Medium two-shot from stage-left side: Character A (forest-green cardigan, acoustic guitar) in the foreground left, Character B (navy button-up) slightly behind to the right at the keyboard. The music stand sits between them with sheet music clipped in place. Warm light highlights the guitar’s wood grain; acoustic panels blur softly in the background.", + "video_prompt": "Three-quarter medium two-shot, 50mm lens. The camera holds steady while Character A strums a quiet chord progression and looks to Character B for the count-in; Character B raises two fingers as a visual cue, then places both hands on the keys, testing a gentle harmony. Their eye contact and subtle head nods coordinate timing." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder shot from behind Character B’s right shoulder: the keyboard keys fill the lower frame, sheet music on the stand centered, and Character A visible beyond the stand with her acoustic guitar. Character C is visible on the right edge, seated on the cajón, attentive and ready. Lighting is even and warm.", + "video_prompt": "Over-the-shoulder medium shot, 35mm lens. The camera remains fixed as Character B plays a simple arpeggio; Character A follows with soft strums, and Character C begins a gentle backbeat with palms on the cajón. The trio starts the first rehearsal groove together, building from very quiet to moderately present." + }, + { + "shot_id": 4, + "first_frame": "Low-angle close-up near the floor focusing on Character C’s hands on the cajón and her black flats planted on the wooden floor. The edge of Character A’s guitar body passes through the left side of frame; the keyboard stand legs appear in the background. The wood textures (cajón faceplate and floor) are crisp.", + "video_prompt": "Low-angle close-up, 70mm lens. The camera pans slightly right as Character C alternates bass hits and snare-like taps; her wrists stay relaxed. The rhythm tightens and becomes more confident, while the guitar strums and keyboard chords are implied by visible movement at frame edges." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Eye-level medium three-shot centered on the trio: Character A stands left with acoustic guitar, Character B stands center at the keyboard, Character C sits right on the cajón. The music stand is between A and B, angled slightly toward them. A small water bottle sits on the floor near the stand. The room feels brighter as the sun shifts, adding a soft highlight on faces.", + "video_prompt": "Eye-level medium shot, 35mm lens. The camera slowly slides laterally from left to right as the trio plays a fuller section; Character A strums more assertively, Character B adds a chord change, and Character C increases rhythmic complexity. Their expressions are focused, with occasional quick glances to the sheet music and each other." + }, + { + "shot_id": 6, + "first_frame": "Over-the-shoulder from behind Character A’s left shoulder: her forest-green cardigan sleeve and the acoustic guitar neck lead into frame. Character B is centered beyond the music stand, and Character C is to the right, poised to play. The sheet music is visible but not readable; binder clips hold pages flat.", + "video_prompt": "Over-the-shoulder medium shot, 40mm lens. The camera holds steady while Character A pauses strumming to point at a measure on the sheet music with her pick hand; Character B leans in slightly and nods, and Character C leans forward, watching the cue. They reset together with a silent count." + }, + { + "shot_id": 7, + "first_frame": "Tight two-shot on Character B (navy button-up, dark beard) and Character C (burgundy sweater, low ponytail). Character B’s hands hover above the keyboard; Character C sits on the cajón turned slightly toward him. The amplifier is softly blurred behind them. Their faces show concentration and problem-solving.", + "video_prompt": "Eye-level tight two-shot, 85mm lens. The camera makes a small push-in as Character B demonstrates a syncopated rhythm by tapping lightly on the keyboard edge (no damage, gentle), then plays the corrected chord entrance; Character C mirrors the timing by tapping a simplified pattern on the cajón. They exchange a quick confirming nod." + }, + { + "shot_id": 8, + "first_frame": "Wide shot from the back of the room: the trio appears mid-distance with the music stand centered. Cables are neatly taped along the floor near the wall; acoustic panels and a high window frame the scene. The overall look is clean and organized, with warm afternoon light now slightly more golden.", + "video_prompt": "Wide shot, 24mm lens. The camera remains locked off as they run the section again; Character A steps half a pace toward the stand while strumming, Character B keeps steady posture at the keys, and Character C maintains consistent hits. The group tightens timing; near the end of the clip, Character A raises her chin as if signaling a transition." + }, + { + "shot_id": 9, + "first_frame": "Side-profile medium shot: Character A in foreground left strumming acoustic guitar, Character C in midground right on cajón, and Character B slightly behind them at the keyboard. The music stand is visible between A and B. The lighting creates a gentle rim on hair and shoulders.", + "video_prompt": "Side-profile medium shot, 50mm lens. The camera slowly pans from Character A toward Character C as they coordinate a dynamic swell: Character A increases strumming intensity, Character C adds stronger accents, and Character B punctuates with brighter chords. Their movements are synchronized, communicating a shared build toward the final chorus." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 10, + "first_frame": "Close-up on the music stand and hands: Character A’s hand (with guitar pick) enters from left to turn a page; Character B’s hand enters from right to steady the paper; a corner of Character C’s burgundy sleeve appears as she points to a line. The paper edges and binder clips are sharp; background softly blurred.", + "video_prompt": "Close-up, 90mm lens. The camera holds still as Character A flips the page carefully; Character B keeps the sheets aligned; Character C taps the page once to mark the starting line. They pull hands back in unison, readying for the final run-through." + }, + { + "shot_id": 11, + "first_frame": "Eye-level medium three-shot with stronger evening-like warm lighting (as if studio lights are now on): Character A (forest-green cardigan) stands left with acoustic guitar, Character B (navy button-up) stands center at keyboard, Character C (burgundy sweater) sits right at cajón. The trio’s posture is upright and prepared; expressions are determined but calm.", + "video_prompt": "Eye-level medium shot, 35mm lens. The camera gently dollies in as Character B gives a clear visual count with his fingers; all three start together precisely. Character A strums steady rhythm, Character B plays a supportive chord progression, and Character C locks a crisp, even beat. The sound and energy feel performance-ready." + }, + { + "shot_id": 12, + "first_frame": "Overhead shot looking down at the trio in a triangle arrangement: music stand at the center, keyboard to the left side of frame, cajón to the right, guitar body visible on the lower-left. The wooden floorboards form clean leading lines; shadows are soft and consistent.", + "video_prompt": "Overhead static shot, 18mm lens. The trio executes the climactic build: Character A leans slightly into the strum pattern, Character B’s hands move confidently across the keys for a brighter voicing, and Character C adds a controlled accent pattern. Their bodies move subtly in time, showing tight ensemble cohesion." + }, + { + "shot_id": 13, + "first_frame": "Medium-wide front-facing shot as if from an imaginary audience position: the trio centered with the music stand slightly off-center. Character A lowers the guitar neck slightly at rest position, Character B’s hands lift from the keys, and Character C’s hands hover above the cajón, all poised at the end of the final chord. Warm, studio-like lighting gives a polished look.", + "video_prompt": "Front-facing medium-wide shot, 28mm lens. The camera holds steady as they finish the last chord together and let it ring; Character A relaxes her shoulders and smiles, Character B exhales and gives a small satisfied nod, and Character C offers a quiet thumbs-up. They share a brief, relieved laugh and begin packing their sheet music neatly, signaling readiness for the performance." + } + ] + } + ], + "metadata": { + "theme_key": "musicians_band_rehearsal", + "theme_description": "Musicians rehearsing together for a performance", + "consistency_type": "Type C", + "requested_scenes": 3, + "requested_shots": 13 + } +} diff --git a/vimax_benchmark/photographer_urban_timelapses_typeA.json b/vimax_benchmark/photographer_urban_timelapses_typeA.json new file mode 100644 index 0000000..4e2dab4 --- /dev/null +++ b/vimax_benchmark/photographer_urban_timelapses_typeA.json @@ -0,0 +1,63 @@ +{ + "story_overview": "A single adult photographer keeps a visual diary by capturing the same city’s changing moods across radically different urban locations and lighting—from sunrise to deep night—culminating in a final long-exposure masterpiece under neon rain.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Early sunrise on a quiet rooftop overlooking a modern city skyline. The main character is clearly defined: an adult woman (32 years old) with warm medium-brown skin, short curly black hair, and round matte-black glasses; she has a small crescent-shaped scar above her right eyebrow. She wears a mustard-yellow field jacket with flap pockets, dark indigo jeans, and clean white sneakers. A black mirrorless camera with a red neck strap hangs from her neck; a compact black backpack sits by her feet. She stands near a low concrete parapet, facing the skyline, soft golden light catching the texture of her jacket. No other people are near. Crisp air, gentle haze over distant buildings.", + "video_prompt": "Eye-level wide shot, 24mm lens, tripod-stable. She lifts the black mirrorless camera with the red strap to her eye and slowly pans her upper body from left to right, framing the skyline as the sun edge brightens. A light breeze ruffles her jacket hem and hair; she lowers the camera slightly to check the rear screen, calm and focused. Warm sunrise light intensifies and glints off her glasses for a moment." + }, + { + "shot_id": 2, + "first_frame": "Hard cut to a bustling morning street market corridor between brick buildings. The same 32-year-old woman with the crescent scar above her right eyebrow, round matte-black glasses, mustard-yellow field jacket, dark indigo jeans, and white sneakers stands beside stacked crates of produce. The black camera with red neck strap rests in her hands at chest height. Colorful awnings create striped shadows; vendors and shoppers are present in the background but not prominent. Moist pavement reflects morning light; hand-lettered signs and chalkboards add texture.", + "video_prompt": "Low angle medium shot, 35mm lens, handheld with gentle sway. She steps sideways to avoid a passing cart, raises the camera, and takes a photo toward the awnings and reflections. She shifts her stance, leans slightly to align leading lines, then lowers the camera to review the image. Background figures move naturally through the corridor while the lighting flickers across her jacket as she turns." + }, + { + "shot_id": 3, + "first_frame": "Hard cut to bright midday at a glass-and-steel business district plaza with a shallow reflecting pool. The same photographer—32, short curly black hair, round matte-black glasses, crescent scar above right eyebrow—wears the same mustard-yellow field jacket, dark indigo jeans, and white sneakers. She stands on pale stone tiles near the pool edge; the black camera with red strap hangs mid-torso. Harsh overhead sunlight creates crisp shadows; mirrored skyscraper facades dominate the background; a few office workers in modest attire pass at a distance.", + "video_prompt": "High angle wide shot, 20mm lens, slow tilt-down from buildings to subject. As the camera tilts, she walks along the pool edge and crouches carefully, bracing elbows on her knees to capture ripples and reflections. She adjusts a lens ring with deliberate fingers, takes one shot, then stands and steps forward, her silhouette sharp against the bright plaza." + }, + { + "shot_id": 4, + "first_frame": "Hard cut to late afternoon on an elevated pedestrian bridge above a multi-lane avenue. The same woman (32) with round matte-black glasses and crescent scar above right eyebrow, mustard-yellow field jacket, dark indigo jeans, and white sneakers leans against a metal railing. The black camera with red strap is mounted to a compact travel tripod placed on the bridge deck. Long shadows stretch across the gridded metal floor; warm amber light hits the railing; traffic streams below as blurred color bands.", + "video_prompt": "Overhead three-quarter shot, 28mm lens, tripod-stable. She tightens the tripod knob, then nudges the camera angle downward toward the flowing traffic. She triggers the shutter with a small remote in her right hand, pauses, then re-frames slightly. Below, cars create continuous motion while the sun drops, intensifying orange highlights along the bridge rail and her jacket seams." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Hard cut to blue-hour dusk at a quiet canal-side walkway lined with modern lampposts and graffiti-tagged retaining walls. The same photographer—32, warm medium-brown skin, short curly black hair, round matte-black glasses, crescent scar above right eyebrow—still in the mustard-yellow field jacket, dark indigo jeans, and white sneakers—stands near the canal railing. The black mirrorless camera with red neck strap is in her hands; the water reflects a deep cobalt sky. Lamplights begin to glow, creating soft pools of light.", + "video_prompt": "Eye-level medium shot, 50mm lens, slow lateral dolly left. She walks a few steps along the railing, stops under a lamppost, and raises the camera to capture the first lights reflecting in the canal. She exhales, steadies, clicks once, then lowers the camera and glances at the screen. The lamplight warms her face and jacket collar while the sky deepens toward night." + }, + { + "shot_id": 6, + "first_frame": "Hard cut to an evening subway platform with glossy tiled walls and a digital timetable board. The same woman (32) with the crescent scar above her right eyebrow and round matte-black glasses, wearing the mustard-yellow field jacket, dark indigo jeans, and white sneakers, stands behind a yellow safety line at a respectful distance. She holds the black camera with red strap close to her chest. Fluorescent lighting casts cool tones; a train’s headlights glow in the tunnel; commuters in modest clothing wait in the background.", + "video_prompt": "Long lens medium shot, 85mm, slight handheld. She pivots her shoulders to frame the oncoming train lights, then quickly raises the camera and takes a photo as the train arrives. The train slides into the station, creating streaks of motion across the frame; she steps one pace back, keeping safe distance, and checks her screen as doors open and people begin to move." + }, + { + "shot_id": 7, + "first_frame": "Hard cut to night in a neon-lit alleyway with wet pavement after a light rain. The same photographer—32, short curly black hair, round matte-black glasses, crescent scar above right eyebrow—wears the same mustard-yellow field jacket, dark indigo jeans, and white sneakers. She stands beside a wall of posters and reflective signage, setting her black camera with red strap onto a small tripod on the ground. Neon signs in blues and magentas reflect in puddles; steam rises from a street vent, creating atmospheric haze.", + "video_prompt": "Low angle close-to-ground wide shot, 18mm lens, slow push-in. She kneels carefully, adjusts the tripod legs, and points the camera toward the neon reflections in a puddle. She wipes a raindrop from her glasses with the back of her sleeve, then starts a long exposure using a remote. The steam curls through the colored light, and the reflections shimmer as distant footsteps pass out of frame." + }, + { + "shot_id": 8, + "first_frame": "Hard cut to the city’s central intersection at deep night, viewed from a corner under a bright storefront awning. The same woman (32) with warm medium-brown skin, short curly black hair, round matte-black glasses, and the crescent scar above her right eyebrow stands firmly with her small tripod set at curbside (well back from the street). She wears the same mustard-yellow field jacket, dark indigo jeans, and white sneakers; the black mirrorless camera with a red neck strap is locked on the tripod, aimed at the crosswalk and traffic lights. Wet asphalt mirrors red, green, and white light trails; tall buildings loom with scattered lit windows. The mood is focused and triumphant.", + "video_prompt": "Corner-position wide shot, 24mm lens, tripod-stable with slight time-lapse feel. She checks the framing, presses the remote, and holds still as cars and buses pass, producing smooth light trails. The traffic signal cycles, pedestrians cross in the distance, and the rain-slick street becomes a canvas of moving color. She leans in to review the final image on the camera screen, then relaxes her shoulders—climax achieved with a clean, dramatic long-exposure city-night photograph." + } + ] + } + ], + "metadata": { + "theme_key": "photographer_urban_timelapses", + "theme_description": "A photographer documenting different times of day in urban settings", + "consistency_type": "Type A", + "requested_scenes": 2, + "requested_shots": 8 + } +} diff --git a/vimax_benchmark/pottery_workshop_studio_space_typeB.json b/vimax_benchmark/pottery_workshop_studio_space_typeB.json new file mode 100644 index 0000000..1a8c100 --- /dev/null +++ b/vimax_benchmark/pottery_workshop_studio_space_typeB.json @@ -0,0 +1,73 @@ +{ + "story_overview": "In a cozy artisan pottery studio, two adult artisans prepare clay, shape a bowl together at the wheel, and culminate in a careful reveal of a freshly formed piece on the central worktable—testing consistent indoor 3D geometry as they move behind shelves, around pillars, and between foreground and background.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a warm, indoor artisan pottery studio with frozen geometry: a concrete floor with clay smudges; a large central wooden worktable with a folded canvas cloth; to frame-left a pottery wheel mounted on a low metal stand; behind it a waist-high rolling cart with tools (wooden ribs, sponge, wire cutter) neatly arranged; frame-right a tall wooden shelving unit filled with bisque-fired bowls and small vases; back wall has two large grid windows admitting soft golden afternoon light; a deep utility sink and faucet sit under the left window; a thick wooden support pillar stands slightly right of center foreground; a hanging pendant lamp above the worktable is off; a small potted plant on the windowsill. Two adult artisans (both 20+): Artisan A (woman, 32, medium height, medium build, warm brown skin, dark curly hair in a low bun, wearing a long-sleeve oatmeal work shirt and a dark olive apron) stands near the sink holding a wrapped block of clay. Artisan B (man, 35, tall, average build, light skin, short dark hair, wearing a denim work jacket over a light gray shirt and a tan apron) stands near the worktable reviewing tools. Modest attire, family-friendly, calm mood.", + "video_prompt": "Eye-level wide shot with a 24mm lens from the studio doorway, static tripod. Artisan A walks from the sink area toward the central worktable, passing behind the center-right support pillar (brief occlusion). Artisan B slides the tool tray closer on the worktable and nods to her. Dust motes drift in the golden window light; all furniture and architectural features remain fixed." + }, + { + "shot_id": 2, + "first_frame": "Medium shot focused on the central worktable from the opposite side (camera now inside the studio looking back toward the windows). The same fixed layout is visible: windows and sink in the background, shelf at frame-left edge, pottery wheel now at frame-right edge. The wooden pillar is now near frame-left foreground. Artisan A sets the wrapped clay block onto the canvas cloth on the table. Artisan B stands on the far side of the table, hands poised above a wooden rib and sponge. Warm afternoon light, soft shadows, tactile wood grain and clay texture.", + "video_prompt": "Eye-level medium shot with a 35mm lens, slight handheld micro-movement. Artisan A unwraps the clay with careful, deliberate motions and presses it onto the canvas. Artisan B points to the wire cutter, then gently slides it toward her across the table. The camera subtly pushes in a few inches; the table, windows, sink, shelves, pillar, and wheel remain perfectly stable in position." + }, + { + "shot_id": 3, + "first_frame": "Low angle close shot near the floor beside the worktable legs, looking across the concrete floor toward the rolling cart. The cart’s wheels and metal frame are sharp; the pottery wheel base is visible behind it. Artisan B’s boots step into frame; Artisan A’s hands reach down from above frame to pick up a wire cutter from the cart’s lower shelf. Clay crumbs on the floor, strong texture detail; warm light rakes across the ground plane.", + "video_prompt": "Low angle close shot with a 28mm lens, camera placed near floor level, locked-off. Artisan B gently nudges the rolling cart a short distance; it rolls and stops with a soft bounce. Artisan A’s hands lift the wire cutter and a sponge, then withdraw upward. Artisan B’s legs move past the cart, briefly blocking it (occlusion), while the studio background geometry stays unchanged." + }, + { + "shot_id": 4, + "first_frame": "Overhead top-down shot of the central worktable: canvas cloth centered; the clay block now unwrapped; a shallow bowl of water at top-left; wooden ribs and sponge arranged to the right; wire cutter coiled near the bottom edge. The pendant lamp fixture is visible above, but unlit. Artisan A’s hands press the clay; Artisan B’s hands steady the cloth edge. The windows are partially visible beyond the table edge, maintaining the same studio orientation.", + "video_prompt": "Overhead static shot with a 20mm lens from directly above the worktable. Artisan A wedges the clay rhythmically—push, fold, turn—while Artisan B sprinkles a few drops of water from the bowl and wipes the table edge. Their hands move in coordinated motions; tools shift slightly but remain on the table plane. No changes to room layout or background elements." + }, + { + "shot_id": 5, + "first_frame": "Medium-wide shot of the pottery wheel area from camera position near the shelving unit (shelf in left foreground, partially blocking the view). The wheel is center frame on its stand; the central worktable is behind it; the pillar sits farther right. Artisan A sits on the wheel stool, posture upright, apron neatly tied, hands hovering over a centered lump of clay on the wheel head. Artisan B stands behind and slightly left, holding a small bowl of water. Warm light continues; the shelf creates depth and foreground occlusion.", + "video_prompt": "Medium-wide shot with a 32mm lens from behind the shelf, slight parallax as the camera gently pans right a few degrees. Artisan A starts the wheel spinning and braces her elbows; Artisan B steps behind the wheel stand, briefly occluded by the shelf edge, then reappears to offer water. The clay begins to rise into a centered mound under steady pressure; the studio’s shelf, pillar, windows, sink, and worktable remain fixed." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 6, + "first_frame": "Tight close-up on the spinning clay at the pottery wheel: Artisan A’s hands (clean nails, no jewelry) cup the clay; a thin sheen of water reflects the warm window light. Artisan B’s hand enters from frame-right holding a sponge near the rim. The wheel head and clay are sharp; background softly blurred but still recognizable as the same studio.", + "video_prompt": "Eye-level close-up with a 85mm lens, camera fixed close to the wheel. The clay spins steadily as Artisan A narrows the form, then opens the center with thumb pressure. Artisan B dabs the sponge lightly to keep it moist. The motion is smooth and controlled; no splashing, no mess beyond small droplets; the sense of depth remains consistent." + }, + { + "shot_id": 7, + "first_frame": "Over-the-shoulder shot from behind Artisan B looking toward Artisan A at the wheel. Artisan B’s denim shoulder and tan apron strap are in the left foreground; Artisan A’s focused face is visible, calm expression, eyes on the clay. The central worktable sits in mid-background; the pillar stands to the right; the windows and sink align exactly as before. Tools on the cart are visible near the wheel.", + "video_prompt": "Over-the-shoulder medium shot with a 50mm lens, slight dolly-in of a few inches. Artisan B gestures with an open palm indicating the rim height; Artisan A responds by lifting the wall of the bowl slightly taller and smoothing the rim. Artisan B leans in, then straightens, maintaining clear separation of bodies and consistent occlusion against the fixed pillar and table." + }, + { + "shot_id": 8, + "first_frame": "Wide shot across the studio from near the sink, looking toward the shelf and wheel. The sink and faucet are now prominent in the left foreground; the pillar is near center; the shelving unit is frame-right. Artisan A carefully lifts a thin cutting wire under the newly formed bowl on the wheel head. Artisan B stands near the worktable, clearing space by moving a folded towel. Warm afternoon light; quiet, anticipatory mood.", + "video_prompt": "Eye-level wide shot with a 26mm lens, tripod static. Artisan A slides the wire cutter beneath the bowl in one continuous motion, then stops the wheel. Artisan B walks from the worktable toward her, passing behind the central pillar (full-body occlusion for a moment) and reappearing near the wheel with a wooden bat. The studio geometry remains unchanged; only the artisans and small objects move." + }, + { + "shot_id": 9, + "first_frame": "Medium shot at the central worktable from the wheel side: the worktable fills the foreground; the pendant lamp hangs above; windows glow in the background; shelf remains at frame-left. Artisan B sets a wooden bat on the table. Artisan A carries the freshly cut bowl on the bat with both hands, arms steady, concentrating. The bowl is simple and smooth, no decoration yet.", + "video_prompt": "Eye-level medium shot with a 40mm lens, gentle lateral move left-to-right to track the placement. Artisan A lowers the bat onto the worktable; Artisan B stabilizes the opposite edge with fingertips, ensuring contact and no sliding. Both exhale and relax their shoulders as the bowl settles—this is the story’s climax: the successful transfer and reveal. All background architecture and furniture stay locked." + }, + { + "shot_id": 10, + "first_frame": "Close-up of the finished wet bowl on the central worktable: the rim is even, surface glossy with water, subtle spiral lines from throwing. A small wooden rib and sponge rest nearby. In soft background blur, the fixed studio elements—pillar, windows, and shelf—remain in their exact positions. Artisan A and B’s hands rest on the table edge, visible at the margins, relaxed.", + "video_prompt": "Static close-up with a 100mm macro lens, camera locked. Artisan A lightly smooths the rim with a damp sponge once, then withdraws her hand. Artisan B places a small maker’s stamp beside the bowl without pressing it into the clay, then pulls back. The shot holds on the bowl as the warm window light glints across its surface, ending on a calm resolution." + } + ] + } + ], + "metadata": { + "theme_key": "pottery_workshop_studio_space", + "theme_description": "A pottery workshop in an artisan studio", + "consistency_type": "Type B", + "requested_scenes": 2, + "requested_shots": 10 + } +} diff --git a/vimax_benchmark/puzzle_solving_escape_room_typeB.json b/vimax_benchmark/puzzle_solving_escape_room_typeB.json new file mode 100644 index 0000000..6476992 --- /dev/null +++ b/vimax_benchmark/puzzle_solving_escape_room_typeB.json @@ -0,0 +1,113 @@ +{ + "story_overview": "In a single, intricately designed escape room, three adult teammates collaborate to solve a sequence of interconnected puzzles—moving through the same fixed space, interacting with furniture and architectural features—until they unlock the final door just before time runs out.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "A wide, eye-level establishing view of a meticulously themed indoor escape room: a brick-and-wood study with a green felt pool table centered under three warm pendant lamps; a mahogany desk with a brass desk lamp sits on the left wall beneath a framed map; a tall bookcase and a rolling library ladder are on the back wall; a vintage wall clock and a glowing digital countdown timer are mounted high to the right; a solid exit door with a rectangular keypad is on the far right wall. A narrow red runner rug leads from foreground toward the back wall. Three adults (all 20+): Mira (28, short black curly hair, tan cardigan over a light blue button-up shirt, dark jeans, white sneakers), Jonas (32, tall, closely shaved head, dark green zip-up jacket, khaki pants, brown shoes), and Priya (30, long dark hair in a low ponytail, maroon sweater, black slacks, gray sneakers) stand near the pool table, looking around with focused, calm expressions. Modest clothing, PG-rated mood, warm practical lighting.", + "video_prompt": "Eye-level wide shot with a 24mm lens from the front-left corner of the room. The camera slowly dollies forward along the red runner rug toward the pool table as the three teammates spread slightly—Mira steps toward the mahogany desk on the left, Jonas circles the near edge of the pool table, and Priya looks up toward the back bookcase and ladder. The countdown timer glows steadily in the background; lighting remains warm and consistent." + }, + { + "shot_id": 2, + "first_frame": "Medium shot from the left side facing the mahogany desk beneath the framed map. The brass desk lamp casts a warm pool of light on scattered, non-copyrighted papers with geometric symbols. Mira (28) is in the foreground, modestly dressed, leaning slightly over the desk. In the deep background, the pool table remains centered; the bookcase and ladder stay fixed against the back wall.", + "video_prompt": "Eye-level medium shot with a 50mm lens aimed at the desk surface. Mira slides a drawer open (wooden drawer moving straight out), revealing a small wooden box with a 3-digit combination dial. She lifts the box onto the desk blotter and rotates it so the dial faces camera, then compares symbols on the papers. The camera makes a subtle push-in, maintaining the room geometry unchanged in the background." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder shot from behind Jonas at the near-right edge of the green felt pool table. The table’s leather pockets and carved wooden rails are clearly visible. Jonas (32) holds a cue stick upright like a pointer, while Priya (30) stands on the far side of the table. Several numbered billiard balls are arranged in a neat triangle near the table’s center. The exit door with keypad is visible far right; the wall clock and digital timer remain mounted in the same places.", + "video_prompt": "Over-the-shoulder medium shot with a 35mm lens from behind Jonas, angled across the pool table. Jonas gently nudges one billiard ball a few inches into alignment with the others (no forceful hit), while Priya points to numbers on two balls and then to the wooden box on the desk area off-frame left. The camera pans slightly right to keep the triangle of balls centered, reinforcing the fixed room layout." + }, + { + "shot_id": 4, + "first_frame": "Low-angle close shot near the floor, framing the base of the tall bookcase on the back wall and the rolling library ladder’s wheels on its rail. Priya (30) kneels modestly on the red runner rug, peering behind the bottom shelf. A small magnetic key with a colored tag is partially visible attached to a metal strip beneath the shelf. The pool table legs appear to the left, stationary, confirming consistent placement.", + "video_prompt": "Low-angle close shot with a 28mm lens positioned near floor level facing the bookcase base. Priya reaches behind the lower shelf, her hand briefly disappearing behind the wooden edge (occlusion), then pulls out the small magnetic key and holds it up to the light. She stands, moving upward out of frame as the camera tilts up slightly to follow the key’s reveal; the ladder and bookcase remain perfectly fixed." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Side wide shot of the back wall: the tall bookcase dominates center; the rolling ladder rests slightly to the left on its rail; a small wall safe (flush-mounted) sits to the right of the bookcase at chest height. Jonas (32) stands near the ladder, one hand on a rung; Priya (30) stands by the safe holding the magnetic key; Mira (28) approaches from left with the wooden combination box. The pool table remains centered in the midground under pendant lights; the desk stays on the left wall.", + "video_prompt": "Eye-level wide shot with a 24mm lens from the room’s mid-right side. Jonas climbs two rungs of the ladder carefully, shifting his weight while holding the side rails. Priya inserts the magnetic key into a slot beside the wall safe; the safe door clicks open slightly. Mira raises the combination box to compare markings on its dial with a small symbol plate on the safe. The camera holds steady to emphasize the unchanging room geometry while the characters interact with fixed structures." + }, + { + "shot_id": 6, + "first_frame": "Tight close-up of the wall safe door now open a few inches, showing a foam-lined interior with three colored tiles and a folded note with a simple arrow diagram (generic). Priya’s hand holds the door edge; Mira’s fingers hover near the tiles. The surrounding wall texture (painted brick) is sharp, and the bookcase edge stays visible left.", + "video_prompt": "Close-up shot with an 85mm lens, eye-level, locked on the safe interior. Mira removes the three colored tiles one by one and places them in her palm; Priya steadies the door. The note flutters slightly as it’s unfolded. The camera racks focus from the tiles to the arrow diagram, then back to the tiles, with no changes to wall placement or safe geometry." + }, + { + "shot_id": 7, + "first_frame": "Medium shot from the left side of the pool table looking toward the exit door and keypad on the far right wall. Jonas (32) stands closest to the keypad, slightly blocking it; Mira (28) and Priya (30) are behind him near the pool table corner. The wall clock above and the digital countdown timer glow on the right wall, both fixed. A small wall-mounted panel with three colored slots is visible beside the keypad.", + "video_prompt": "Eye-level medium shot with a 40mm lens aimed at the keypad area. Jonas steps closer and leans in (his torso partially occluding the keypad), while Mira hands him the three colored tiles. He inserts them into the three-slot panel in the order Priya indicates with pointed gestures. A soft indicator light turns from red to green. The camera makes a slight handheld-style settle (very subtle) while maintaining the room’s consistent layout." + }, + { + "shot_id": 8, + "first_frame": "Overhead shot looking straight down at the green felt pool table. The billiard balls form a new pattern: three balls aligned in a row near the head spot, and a paper note with the arrow diagram lies flat near the center. Mira’s hands rest on the table edge at bottom of frame; Priya’s hands point from the paper to the balls; Jonas’s forearms appear at top edge, steadying a ball.", + "video_prompt": "Overhead top-down shot with a 16mm lens on a fixed rig above the pool table. The team repositions three balls with careful, small movements to match the arrow diagram. As the last ball clicks into place, a faint mechanical sound is implied from the right wall. The camera remains perfectly static to reinforce the pool table’s fixed position and the room’s structural continuity." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Medium-wide shot from behind the mahogany desk on the left wall, facing across the room: the pool table remains centered; the back bookcase and ladder are aligned on the back wall; the exit door and keypad remain on the far right wall. A small compartment in the desk’s side panel is now ajar. Mira (28) stands at the desk; Jonas (32) and Priya (30) move toward her along the red runner rug.", + "video_prompt": "Eye-level medium-wide shot with a 28mm lens from the desk side. Mira opens the desk’s side compartment fully and pulls out a compact, non-copyrighted metal cylinder (a flashlight-like UV lamp) and a laminated card with faint markings. Jonas and Priya step into the foreground, stopping at the desk corner. The camera pans slightly left-to-right to keep the extracted items centered, keeping the room’s fixed elements consistent." + }, + { + "shot_id": 10, + "first_frame": "Close shot of a blank-looking section of the brick wall between the bookcase and the exit door. Jonas (32) stands close to the wall, holding the UV lamp; Priya (30) holds the laminated card as reference; Mira (28) watches from behind. The wall clock and digital timer are still high on the right wall, unchanged.", + "video_prompt": "Eye-level close shot with a 50mm lens angled toward the wall. Jonas switches on the UV lamp and slowly sweeps the beam across the bricks; hidden symbols appear in the light, forming a short sequence of shapes. Priya compares the sequence to the laminated card and traces the order in the air. The camera tracks a few inches with the UV beam, emphasizing the wall’s fixed texture and location." + }, + { + "shot_id": 11, + "first_frame": "Wide shot from the far back-left corner looking diagonally across the entire room: bookcase and ladder dominate left-back; pool table centered; desk on left wall; exit door with keypad on right wall. A small floor-level hatch panel near the baseboard under the timer is visible on the right wall. Priya (30) crouches at the hatch; Jonas (32) kneels beside her; Mira (28) stands behind them holding the UV lamp. Their poses are calm, focused, non-violent.", + "video_prompt": "Wide shot with a 24mm lens from the back-left corner. Priya opens the floor-level hatch downward; Jonas reaches in (his arm briefly occluded by the hatch door) and retrieves a coiled cable with a plug and a small, palm-sized power module. Mira shines the UV lamp briefly into the hatch, then turns it off. The camera remains steady, reinforcing the room’s consistent 3D geometry and fixed props." + }, + { + "shot_id": 12, + "first_frame": "Medium shot near the exit door keypad area on the right wall. The wall-mounted three-slot panel remains beside the keypad, already filled with colored tiles. Below it, a small recessed port is visible, matching the cable plug. Jonas (32) sits on a low bench fixed against the right wall (bench position constant), holding the cable; Priya (30) stands to his left; Mira (28) stands slightly behind, watching the timer.", + "video_prompt": "Eye-level medium shot with a 35mm lens facing the right wall. Jonas plugs the cable into the recessed port; Priya holds the power module steady against the wall as it snaps into place with a soft click. A subtle indicator light on the keypad brightens. Mira points at the timer as it ticks down, then nods. The camera performs a slow push-in toward the keypad, keeping the exit door and wall fixtures perfectly aligned and unchanged." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Over-the-shoulder shot from behind Mira (28) toward the exit door keypad. The keypad display shows blank entry fields (no readable brand text). Jonas (32) stands closest to the keypad; Priya (30) stands slightly behind him, holding the laminated card with UV symbols. The digital countdown timer remains above and to the right, glowing with limited time remaining. The pool table and pendant lights are visible in the left background, fixed.", + "video_prompt": "Over-the-shoulder medium shot with a 45mm lens from behind Mira. Jonas enters a code derived from the UV symbol sequence, pressing buttons deliberately. Priya quietly points to the next symbol on the card; Mira steadies the card’s corner. The camera holds position while Jonas’s shoulder partially blocks the keypad at moments, testing occlusion without changing the room layout." + }, + { + "shot_id": 14, + "first_frame": "Close-up of the exit door’s metal handle and latch area on the right wall. The door surface is matte painted wood with a small rectangular window (frosted). The latch is still engaged at the start of the frame. A soft green glow from the keypad area reflects on the metal.", + "video_prompt": "Tight close-up with a 100mm lens, eye-level on the latch. The latch retracts with a crisp mechanical motion; the handle depresses slightly from Jonas’s unseen hand (hand enters frame briefly). The door cracks open a few inches, revealing warm hallway light spilling in. The camera remains locked, emphasizing the physical realism of the door hardware." + }, + { + "shot_id": 15, + "first_frame": "Wide shot from the room’s center-left side capturing the team and the opened exit door on the right. The pool table remains centered; desk on left wall; bookcase and ladder on back wall; wall clock and digital timer on right wall. The door is open wider now, and brighter light from outside frames the doorway. Jonas (32) holds the door; Priya (30) and Mira (28) stand behind him with relieved, happy expressions.", + "video_prompt": "Eye-level wide shot with a 24mm lens. Jonas pulls the door open fully, stepping slightly backward to clear the threshold; Priya and Mira walk forward along the red runner rug toward the doorway. Their bodies briefly occlude the keypad as they pass. The camera pans gently right to follow their movement, maintaining the fixed geometry of all furniture and walls." + }, + { + "shot_id": 16, + "first_frame": "Medium shot from just inside the room facing toward the doorway (camera still in the escape room), framing the team at the threshold. The open door occupies the right third of the frame with bright hallway light; the interior behind them shows the consistent pool table, pendant lights, and back bookcase. All three adults stand close together, modestly dressed, smiling and calm, with hands relaxed at their sides.", + "video_prompt": "Eye-level medium shot with a 35mm lens, positioned a few feet inside the room aimed at the open doorway. The three teammates pause, exchange satisfied nods, and step through the doorway one after another (Jonas first, then Priya, then Mira), each briefly crossing in front of the doorframe and partially blocking the light. After they exit, the door remains open, and the camera holds for a beat on the unchanged room interior and steady lighting." + } + ] + } + ], + "metadata": { + "theme_key": "puzzle_solving_escape_room", + "theme_description": "A team solving puzzles in an escape room", + "consistency_type": "Type B", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/science_demo_university_lab_typeB.json b/vimax_benchmark/science_demo_university_lab_typeB.json new file mode 100644 index 0000000..39fa7bf --- /dev/null +++ b/vimax_benchmark/science_demo_university_lab_typeB.json @@ -0,0 +1,73 @@ +{ + "story_overview": "In a university teaching laboratory, an adult instructor and two adult students set up and safely run a dramatic but family-friendly chemical indicator demonstration, culminating in a color-change reveal that confirms their hypothesis.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a bright university laboratory classroom. The 3D room layout is clear and must remain identical in all shots: a central long stainless-steel lab bench with two sinks (one near left third, one near right third), three black lab stools tucked under the bench, a tall fume hood on the back-left wall with its glass sash half-raised, a whiteboard centered on the back wall with a marker tray, a shelving unit on the back-right wall holding labeled reagent bottles and folded paper towels, and a doorway on the far-right wall. Overhead fluorescent panels cast even, cool light. On the central bench: a blue absorbent spill mat, a clear beaker, a glass stirring rod, a small digital scale, a tray of safety goggles, and a small pH indicator bottle with a dropper cap. Three adults (20+), modestly dressed in button-up shirts and slacks under white lab coats, stand near the bench: the instructor at the bench center, two students to the right. Everyone wears safety goggles; no exposed skin beyond hands and face.", + "video_prompt": "Eye-level wide shot with a 24mm lens from the front-left corner of the room. The camera holds steady with a subtle slow push-in toward the central bench as the instructor gestures toward the beaker and the students nod. The students step closer to the bench, keeping the same room geometry visible: fume hood back-left, whiteboard centered, shelving back-right, doorway far-right." + }, + { + "shot_id": 2, + "first_frame": "Medium shot from the right side of the central bench, keeping the fixed background: the doorway on the far-right wall is visible behind the students, and the shelving unit on the back-right wall aligns behind them. The instructor (adult, 20+) stands on the left side of frame at the bench edge, lab coat sleeves down, gloved hands near the tray. One student (adult, 20+) is seated on the nearest stool, the other stands behind the stool. The tray of goggles and nitrile gloves sits on the blue spill mat; the beaker and stirring rod are to the left of the mat; the digital scale is near the center.", + "video_prompt": "Eye-level medium shot with a 50mm lens from the room’s right aisle, angled slightly left toward the bench. The instructor slides a pair of gloves across the bench to the seated student, who reaches forward to take them. The standing student leans in to look at the digital scale, then straightens, maintaining clear contact with the stool and bench." + }, + { + "shot_id": 3, + "first_frame": "Close-up of hands and tools on the central bench, with the stainless surface texture and blue spill mat prominent. The beaker sits on the digital scale; the pH indicator bottle stands upright to the right; the stirring rod lies diagonally on the mat. In the blurred background, the same fixed room elements remain: the whiteboard centered and the fume hood back-left. Gloved adult hands (20+) enter frame from left and right, poised above the beaker and indicator bottle.", + "video_prompt": "Top-down close-up with a 70mm lens, camera locked directly above the beaker and scale. One pair of gloved hands steadies the beaker while the other carefully adds a measured amount of clear liquid (water) and taps the scale button. The indicator bottle is nudged slightly to align with the beaker, then hands withdraw, leaving the setup neat and centered." + }, + { + "shot_id": 4, + "first_frame": "Over-the-shoulder view from behind the instructor toward the whiteboard on the back wall, preserving the room’s fixed geometry: central bench in foreground, fume hood back-left, shelving back-right. The whiteboard shows a simple handwritten title: 'Indicator Demo: Hypothesis' and a basic pH scale sketch. The instructor’s lab coat shoulder and goggles frame the left foreground. The two adult students (20+) stand to the right, one partially occluded by the shelving edge as they shift position near the bench.", + "video_prompt": "Over-the-shoulder medium shot with a 35mm lens from behind the instructor, aimed at the whiteboard and students. The instructor points to the pH scale sketch with a capped marker while speaking; the students nod and one steps left, briefly passing behind the instructor’s shoulder (occlusion), then reappears to face the bench." + }, + { + "shot_id": 5, + "first_frame": "Medium-wide shot facing the fume hood on the back-left wall, keeping the entire room layout consistent: central bench runs horizontally across mid-frame, stools underneath, whiteboard centered behind, shelving back-right, doorway far-right. The instructor and one student walk along the front edge of the bench toward the fume hood; the second student remains near the right side of the bench. The instructor carries a sealed container of baking soda (clearly labeled) and a small measuring spoon; all adults (20+) wear goggles, gloves, and lab coats with modest attire underneath.", + "video_prompt": "Eye-level medium-wide shot with a 28mm lens positioned near the center aisle, looking toward the fume hood. The instructor and student move left-to-right across the frame, then curve toward the fume hood, briefly disappearing behind the fume hood frame edge (partial occlusion) before stopping. The student at the right side of the bench leans over the spill mat to check the beaker, then looks up toward the instructor." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 6, + "first_frame": "Wide shot from the back-right corner of the same laboratory, ensuring the fixed geometry is unmistakable: the shelving unit is now close in the right foreground, the doorway is visible far-right, the whiteboard centered on the back wall, and the fume hood back-left. The central bench dominates the middle with the blue spill mat, beaker, indicator bottle, stirring rod, and a small tray containing a cup labeled 'Baking Soda Solution' and a bottle labeled 'Vinegar Solution' (household, safe). The instructor (adult, 20+) stands at the bench center; the two adult students (20+) stand on either side, one near the right stools.", + "video_prompt": "High angle wide shot with a 24mm lens from the back-right corner, camera slightly tilted down. The instructor directs the students with calm hand motions; one student steps forward from background to foreground to place the labeled solutions on the mat, then steps back, demonstrating depth and consistent placement on the solid bench surface." + }, + { + "shot_id": 7, + "first_frame": "Medium two-shot at the central bench from the front, maintaining the same room background: whiteboard centered behind, fume hood back-left, shelving back-right. The instructor stands on the left; a student stands on the right. The beaker sits centered on the blue mat; the pH indicator bottle is in the instructor’s gloved hand, dropper tip above the beaker. The student holds the stirring rod above the beaker, ready but not touching yet.", + "video_prompt": "Eye-level medium two-shot with a 50mm lens from directly in front of the bench. The instructor squeezes in several drops of indicator into the clear beaker; the student lowers the stirring rod and stirs slowly. The liquid shifts from clear to a faint tint as it mixes, with gentle swirling motion visible while both maintain steady posture." + }, + { + "shot_id": 8, + "first_frame": "Close-up on the beaker and hands, with the stainless surface reflections and blue mat texture sharp. The beaker now contains lightly tinted liquid. A small cup labeled 'Vinegar Solution' sits to the left; a small cup labeled 'Baking Soda Solution' sits to the right. Gloved adult hands (20+) hover: one holds a small measuring spoon above the baking soda cup; the other steadies the beaker. Background remains the same lab room but blurred, with the whiteboard’s bright rectangle centered.", + "video_prompt": "Macro close-up with an 85mm lens, camera fixed at bench height, slightly angled down. A measured spoonful is added into the beaker and the mixture swirls; the color begins to shift more noticeably. The spoon withdraws and taps lightly on the cup rim; the beaker stays planted on the mat, showing solid contact with the surface." + }, + { + "shot_id": 9, + "first_frame": "Medium shot from the left side of the bench looking toward the shelving back-right, preserving the unchanging room geometry. One student (adult, 20+) sits on a stool, leaning forward with elbows near (not on) the bench edge; the other student stands behind them. The instructor stands opposite, slightly left of frame. The beaker is centered; a small handheld pH strip container lies beside it. The seated student reaches for a pH strip while keeping goggles on. The standing student is partially occluded by the seated student and stool backrest, emphasizing depth and occlusion with fixed furniture.", + "video_prompt": "Eye-level medium shot with a 35mm lens from the left aisle, angled toward the shelving. The seated student pulls a single pH strip, dips it briefly into the beaker, then lifts it to compare against the strip color chart on the container. The instructor leans in, then steps back, and the standing student shifts sideways, briefly disappearing behind the seated student’s shoulder before reappearing." + }, + { + "shot_id": 10, + "first_frame": "Dramatic final wide shot centered on the bench with the entire lab layout unchanged: fume hood back-left, whiteboard centered, shelving back-right, doorway far-right, stools under the bench. The beaker now shows a vivid, family-friendly color change (e.g., bright purple or deep blue) indicating the completed reaction/indicator shift. The instructor and two adult students (20+) stand behind the bench in a neat line, modest attire under lab coats, goggles on, gloves on. The whiteboard behind them now includes a large check mark next to 'Hypothesis Confirmed' written in clear block letters.", + "video_prompt": "Eye-level wide shot with a 28mm lens from the front center of the room. The camera performs a slow, steady push-in toward the beaker as the instructor lifts the beaker slightly (kept level, no spilling) to display the final color. The students point toward the whiteboard check mark, then give a small, contained celebratory gesture (thumbs-up and nods). The shot ends with the beaker centered in frame, the fixed lab environment stable and unwarped." + } + ] + } + ], + "metadata": { + "theme_key": "science_demo_university_lab", + "theme_description": "A science demonstration in a university laboratory", + "consistency_type": "Type B", + "requested_scenes": 2, + "requested_shots": 10 + } +} diff --git a/vimax_benchmark/scientist_natural_environments_typeA.json b/vimax_benchmark/scientist_natural_environments_typeA.json new file mode 100644 index 0000000..3b9d584 --- /dev/null +++ b/vimax_benchmark/scientist_natural_environments_typeA.json @@ -0,0 +1,88 @@ +{ + "story_overview": "A single adult scientist tests an eco-friendly sensor prototype across dramatically different natural environments, collecting data and refining the setup until a final stormy-night field test proves the device can reliably detect early signs of environmental change.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Interior, compact field lab trailer with matte gray cabinets and a tidy workbench. Center frame: Dr. Mara Ellison, a 34-year-old woman with warm medium-brown skin, almond-shaped dark eyes behind thin round silver eyeglasses, and a single silver streak in her otherwise dark hair pulled into a low bun. She wears a modest forest-green field jacket zipped to the collar, a light gray turtleneck, khaki cargo pants, and brown hiking boots; an ID badge clipped to her left chest pocket. On the bench: a palm-sized sensor module with a small amber status LED, coiled cables, a rugged tablet, and labeled sample vials. Soft daylight through a small window; calm, focused mood.", + "video_prompt": "Eye-level medium shot, 35mm lens, locked-off camera. Dr. Mara Ellison taps the rugged tablet, then gently rotates the sensor module in her gloved hands, watching the amber LED blink steadily. She marks a checklist with a pen, nods once, and closes a small foam case with the sensor inside; subtle lab hum, natural daylight remains consistent." + }, + { + "shot_id": 2, + "first_frame": "Bright alpine meadow under clear midday sun with wildflowers and distant snowcapped peaks. Dr. Mara Ellison (same face, glasses, silver hair streak, and the same forest-green jacket, gray turtleneck, khaki cargos, brown boots, ID badge) kneels beside a flat rock in the foreground, placing the sensor module on a small tripod. The rugged tablet sits on the rock, screen reflecting blue sky; crisp, high-contrast lighting and vivid colors.", + "video_prompt": "Low-angle wide shot, 24mm lens. Dr. Mara Ellison adjusts the tripod legs on uneven ground and presses a button on the sensor; the amber LED pulses. She stands, steps back into the meadow, and raises the tablet to verify readings, turning slightly so sunlight glints off her round silver eyeglasses; a light breeze moves grass and flowers." + }, + { + "shot_id": 3, + "first_frame": "Dense tropical rainforest at dusk, humid haze and shafts of greenish light cutting through tall leaves. Dr. Mara Ellison, unchanged in appearance and clothing, stands on a narrow muddy path with a small backpack. In the near foreground, large wet leaves frame the shot; midground shows her holding the sensor module near a mossy tree trunk. Moist surfaces gleam; the mood is exploratory and cautious.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens, slight handheld sway. From behind her right shoulder, Dr. Mara Ellison clips the sensor to a strap around the tree trunk, then wipes moisture from the tablet screen with a sleeve. She leans closer to check the amber LED through the mist, then steps carefully around a puddle, keeping the device stable; light beams shift subtly as leaves sway." + }, + { + "shot_id": 4, + "first_frame": "Coastal tidepool at golden hour with warm sunlight, wet rocks, and shallow pools reflecting the sky. Dr. Mara Ellison (same identity and outfit) crouches on a stable rock ledge in the foreground, extending a gloved hand to lower the sensor module into a clear tidepool using a short tether. The tablet rests in a waterproof case nearby; gentle waves in the background. Warm highlights, crisp reflections.", + "video_prompt": "Top-down oblique shot, 28mm lens, slow controlled pan. Dr. Mara Ellison lowers the sensor into the tidepool until it is just submerged, then watches the amber LED refract under water. She lifts the tablet, taps to start a timed reading, and carefully repositions her boots to keep balance on the slick rock; the camera pans slightly to follow the sensor’s placement and the rippling reflections." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Arid desert plateau at noon with pale sand, scattered stones, and distant mesas under harsh white sunlight. Heat shimmer is visible. Dr. Mara Ellison, still in the same forest-green jacket and modest layered clothing, stands beside a small foldout tripod table holding the tablet and sensor. A lightweight windscreen made of fabric is clipped to the table. High contrast, dry textures, intense glare.", + "video_prompt": "Eye-level medium-wide shot, 32mm lens. Dr. Mara Ellison squints behind her glasses, raises a hand to shade the tablet, and secures the windscreen as a gust flutters it. She rotates the sensor module toward the wind, then notes a fluctuating readout on the tablet; the amber LED flickers faster, prompting her to tighten a cable and steady the setup." + }, + { + "shot_id": 6, + "first_frame": "Polar shoreline at blue hour with snow-covered rocks and calm dark water; cold, cobalt lighting. Dr. Mara Ellison (same face, glasses, silver hair streak, same jacket, turtleneck, cargos, boots, ID badge) stands on packed snow, breath faintly visible. She holds the sensor module in both hands, examining a small rim of frost forming near the LED. The tablet is strapped to her forearm with a rugged band. Quiet, crisp atmosphere.", + "video_prompt": "Close-up, 85mm lens, slight push-in. Dr. Mara Ellison gently rubs the sensor casing with a gloved thumb to clear frost without removing it, then taps the tablet with her other hand to activate a warming cycle. The amber LED steadies, and she exhales in relief; the camera continues a subtle push-in to emphasize the LED’s consistent pulse against the cold blue environment." + }, + { + "shot_id": 7, + "first_frame": "Misty conifer forest at dawn with soft gray fog and dew on needles. Dr. Mara Ellison, unchanged in identity and clothing, stands midground beside a fallen log. A small portable antenna is mounted on a tripod; the sensor module is connected by a cable. The tablet sits on the log, its screen casting a faint cool glow. The scene is muted, quiet, and methodical.", + "video_prompt": "Side-profile medium shot, 40mm lens, slow lateral dolly. Dr. Mara Ellison aligns the antenna toward a clearing, then runs a quick calibration on the tablet. She lifts the sensor, taps it twice, and places it back, watching the amber LED blink in a stable rhythm; the dolly move reveals fog drifting between tree trunks, reinforcing a changing environment while she remains visually consistent." + }, + { + "shot_id": 8, + "first_frame": "Mountain river gorge in bright afternoon light with fast-moving turquoise water and rugged rock walls. Dr. Mara Ellison (same identity and outfit) stands on a sturdy wooden footbridge in the foreground, clipping the sensor module to the railing with a clamp. Her tablet is secured by a shoulder strap, resting against her jacket. Sunlight sparkles on water; dynamic, energetic mood without danger.", + "video_prompt": "High-angle medium shot, 26mm lens, gentle tilt. Dr. Mara Ellison tightens the clamp, then leans slightly to aim the sensor toward the river’s spray, keeping a safe stance. She taps the tablet, and the amber LED switches from slow pulse to a steady glow as the reading stabilizes. The camera tilts down briefly to show the churning water, then back up to her focused expression." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Volcanic geothermal field under overcast sky with steaming vents and rust-colored mineral deposits. The ground is dark and textured, with plumes of white steam drifting. Dr. Mara Ellison (same face, glasses, silver streaked bun, same jacket, turtleneck, cargos, boots, ID badge) stands in the foreground wearing sturdy gloves, holding the sensor module near a vent but at a safe distance. The tablet is on a tripod stand; moody, diffused lighting.", + "video_prompt": "Low-angle medium shot, 35mm lens, slight handheld. Dr. Mara Ellison carefully extends her arms to position the sensor on a heat-resistant stake in the ground, then steps back and checks the tablet’s graph. Steam wafts between her and the camera, briefly obscuring her jacket and glasses before clearing; the amber LED remains visible, blinking steadily through the haze." + }, + { + "shot_id": 10, + "first_frame": "Nighttime wetland boardwalk during a light rain, illuminated by a headlamp beam and distant lightning-free storm clouds. Raindrops bead on wooden planks and reeds sway. Dr. Mara Ellison, unchanged in identity and wearing the same modest field outfit, stands under a simple rain hood attached to her forest-green jacket (hood up but same glasses and silver hair streak still visible). She holds the sensor module over a marshy inlet; the tablet is protected inside a clear waterproof sleeve. Cool, cinematic lighting with reflective surfaces.", + "video_prompt": "Eye-level close medium shot, 50mm lens, slow push-in. Dr. Mara Ellison anchors the sensor to a short pole beside the boardwalk and starts a final test on the tablet. Rain streaks across her glasses; she wipes them with the back of her glove and watches the amber LED shift to a brighter, steady pulse. The camera pushes in as the tablet graph climbs smoothly, emphasizing the device’s improved stability in harsh weather." + }, + { + "shot_id": 11, + "first_frame": "Sunrise salt flat after the storm, vast mirror-like shallow water reflecting pink-orange sky. Dr. Mara Ellison (same identity and outfit; hood down again, silver hair streak visible in the bun; glasses clean) stands in the center of the frame with the sensor module mounted on a small tripod at her feet. The tablet is held in both hands, reflection visible in the wet surface. Serene, triumphant atmosphere.", + "video_prompt": "Wide shot, 20mm lens, slow backward dolly. Dr. Mara Ellison lifts the tablet slightly, confirming a stable final readout, then smiles subtly and gives a small satisfied nod. She picks up the sensor tripod and walks forward a few steps across the reflective surface, her boots creating gentle ripples; the camera dollies back to keep her centered against the expansive mirrored horizon." + }, + { + "shot_id": 12, + "first_frame": "Interior of the same compact field lab trailer, now lit by warm morning light through the window. Dr. Mara Ellison (same face, glasses, silver streak, same forest-green jacket, gray turtleneck, khaki cargos, brown boots, ID badge) sits at the workbench with the sensor module placed on a padded mat and the tablet propped up showing a clean, stable data line. Neat notes and labeled charts are pinned on a corkboard. Calm, resolved mood.", + "video_prompt": "Overhead medium shot, 35mm lens, locked-off. Dr. Mara Ellison slides a printed summary sheet next to the tablet, draws a final checkmark, and gently closes the sensor into its foam case. She places the case beside the tablet, then rests her hands on the bench for a beat as warm light fills the trailer; the shot ends with the stable graph visible, signaling the successful conclusion." + } + ] + } + ], + "metadata": { + "theme_key": "scientist_natural_environments", + "theme_description": "A scientist conducting experiments in various natural environments", + "consistency_type": "Type A", + "requested_scenes": 3, + "requested_shots": 12 + } +} diff --git a/vimax_benchmark/scientists_lab_collaboration_typeC.json b/vimax_benchmark/scientists_lab_collaboration_typeC.json new file mode 100644 index 0000000..af3df7c --- /dev/null +++ b/vimax_benchmark/scientists_lab_collaboration_typeC.json @@ -0,0 +1,93 @@ +{ + "story_overview": "In a bright university laboratory, two adult scientists collaborate to calibrate a tabletop experiment, troubleshoot a sudden instability, and successfully stabilize the system to capture a clean final measurement.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide, eye-level establishing view of a modern university laboratory. Two adult scientists stand at a clean central workbench: Scientist A is a 34-year-old woman, tall and slim, medium-brown skin, short curly black hair, wearing a forest-green lab coat over a light gray turtleneck, dark slacks, and black closed-toe shoes; she has rectangular black eyeglasses and a calm, focused expression. Scientist B is a 41-year-old man, average height with a sturdy build, light skin, short dark hair with light stubble, wearing a navy-blue lab coat over a pale blue button-up shirt, khaki trousers, and brown closed-toe shoes; he wears clear safety goggles. On the bench: a compact optical experiment setup with a small metal breadboard, two silver posts, a black cylindrical sensor, a small glass chamber with a sealed lid, and a laptop to the right. Background: shelves with labeled containers, a whiteboard with neat diagrams, and a large window casting soft daylight. Everything is modest, professional, and PG.", + "video_prompt": "Eye-level wide shot with a 24mm lens from across the workbench. Scientist A gestures toward the whiteboard and then points to the glass chamber; Scientist B nods and taps a checklist on a clipboard. Both lean slightly toward the apparatus, speaking silently, while daylight glows through the window and lab monitors remain steady." + }, + { + "shot_id": 2, + "first_frame": "Medium two-shot from the left side of the bench. Scientist A (green lab coat, black rectangular glasses) stands closer to camera, holding a capped marker; Scientist B (navy lab coat, clear goggles) stands beside her with a clipboard. The whiteboard fills the background with a simple graph and arrows; the optical breadboard is partially visible at frame bottom right.", + "video_prompt": "Medium two-shot, slight low angle with a 35mm lens. Scientist A writes a short equation and a small diagram on the whiteboard, then underlines it; Scientist B points at the graph with his pen and flips a page on the clipboard. Their bodies shift subtly, maintaining a collaborative stance, with crisp fluorescent overhead lighting and soft window fill." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder shot from behind Scientist B (navy coat, goggles) looking toward the laptop screen on the right side of the bench. Scientist A (green coat, glasses) is across the bench, hands near the sealed glass chamber. The laptop shows a clean interface with a live line graph and numeric readouts (generic, no brand). Cables run neatly from the sensor to the laptop.", + "video_prompt": "Over-the-shoulder close-medium shot, eye-level with a 50mm lens behind Scientist B. Scientist B moves the trackpad to start a calibration; the line graph begins to move smoothly. Scientist A steadies the glass chamber with one hand and adjusts a small silver knob with the other, glancing between the chamber and Scientist B for confirmation." + }, + { + "shot_id": 4, + "first_frame": "Insert close-up of hands at the apparatus: Scientist A’s hands (green sleeves) hold a small hex key near a silver adjustment post; Scientist B’s hands (navy sleeves) hold a labeled connector cable and a small torque-limited screwdriver. The black cylindrical sensor sits centered, with a tiny status LED glowing green.", + "video_prompt": "Macro close-up, slightly high angle with a 85mm lens. Scientist B plugs in the connector with a careful push until it clicks, then lightly tightens a fastener; Scientist A turns the hex key a quarter-turn and pauses. The sensor LED remains green, and the cables settle without snagging." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Medium two-shot from the opposite side of the bench, facing the scientists. Scientist A (green coat, glasses) holds a laminated calibration card; Scientist B (navy coat, goggles) stands at the laptop, one hand hovering over the keyboard. The glass chamber is centered between them. Lighting is slightly cooler from overhead fluorescents, with gentle reflections on metal components.", + "video_prompt": "Eye-level medium two-shot with a 40mm lens. Scientist A reads the calibration card and points to a specific step; Scientist B types a short command and nods. The laptop graph responds with a subtle change in slope as they exchange quick, focused looks." + }, + { + "shot_id": 6, + "first_frame": "Close-up of the laptop screen and Scientist B’s gloved hands (thin nitrile gloves) on the keyboard. The interface shows a live waveform and a numeric stability indicator. Scientist A’s green sleeve enters frame from left, pointing at a threshold line on the display.", + "video_prompt": "Eye-level close-up with a 65mm lens focused on the screen. Scientist B increases a parameter in small increments; the waveform tightens, then briefly wobbles. Scientist A’s finger traces the threshold line, indicating where to stop, and the stability indicator flickers from steady to caution for a moment." + }, + { + "shot_id": 7, + "first_frame": "Tight two-shot at the glass chamber. Scientist A (green coat, glasses) leans in, eyes intent; Scientist B (navy coat, goggles) holds a small handheld meter with a digital readout. The chamber’s sealed lid and gasket are clearly visible; the metal breadboard shows neatly aligned posts. The mood is concentrated.", + "video_prompt": "Tight two-shot, slight high angle with a 55mm lens. Scientist A gently rotates the chamber’s lid a few degrees to align a mark, then stops; Scientist B raises the handheld meter closer to the sensor and reads it aloud silently, then gestures a small 'hold' motion. Both keep steady hands as the apparatus hums quietly (implied)." + }, + { + "shot_id": 8, + "first_frame": "Wide shot from the back of the lab, showing the whole workbench and surrounding shelves. Scientist A and Scientist B stand on either side of the bench. A small desk fan in the background is off; a lab door is closed. The laptop graph is visible as a faint glow. The scene feels orderly but tense.", + "video_prompt": "Wide shot, slightly elevated with a 24mm lens. The laptop waveform suddenly becomes jagged; Scientist B leans forward quickly and raises one hand to pause input, while Scientist A braces the chamber with both hands and looks sharply at the screen. Their movements are controlled and careful, indicating an unexpected instability without any danger." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Over-the-shoulder shot from behind Scientist A (green coat, glasses) toward Scientist B (navy coat, goggles) at the laptop. Scientist B’s face is focused; his right hand hovers over an emergency stop icon (software control) on the screen. The jagged waveform is clearly visible. The bench components remain aligned and intact.", + "video_prompt": "Over-the-shoulder medium shot, eye-level with a 45mm lens behind Scientist A. Scientist B clicks to reduce power/output and disables a single module; the jagged waveform begins to dampen. Scientist A nods once, then points to a cable route near the sensor, suggesting a minor interference source." + }, + { + "shot_id": 10, + "first_frame": "Insert close-up of the cable bundle near the sensor. Scientist A’s hands (green sleeves) lift a cable gently away from a metal edge; Scientist B’s hands (navy sleeves) place a small adhesive cable clip to secure it. The sensor LED now blinks slowly, transitioning from caution to normal.", + "video_prompt": "Macro close-up, low angle with a 90mm lens. Scientist A repositions the cable to create a clean loop and holds it steady; Scientist B presses the cable clip into place and runs a finger along the cable to confirm it’s seated. The LED changes to a steady green as the cable stops moving." + }, + { + "shot_id": 11, + "first_frame": "Medium two-shot at the bench with the glass chamber centered. Scientist A (green coat, glasses) holds the calibration card at chest height; Scientist B (navy coat, goggles) rests one hand on the laptop and the other on the handheld meter. Both stand upright, poised to retry. Lighting is brighter as if the window sun has shifted slightly.", + "video_prompt": "Eye-level medium two-shot with a 35mm lens. Scientist A counts down silently with three fingers, then lowers her hand; Scientist B restarts the run and watches the meter. The waveform on the laptop (off to the side) stabilizes smoothly, and both scientists relax their shoulders slightly while staying attentive." + }, + { + "shot_id": 12, + "first_frame": "Close-up reaction two-shot of their faces side-by-side, framed from mid-chest up. Scientist A’s rectangular black glasses catch a soft reflection; Scientist B’s clear goggles reveal focused eyes. Both show restrained, professional satisfaction. Background bokeh includes the whiteboard and shelves.", + "video_prompt": "Eye-level close-up two-shot with a 70mm lens. Scientist A glances from the screen to Scientist B and gives a small approving nod; Scientist B exhales, then returns a brief thumbs-up near his chest (not exaggerated). Their expressions remain calm and PG, emphasizing teamwork and success." + }, + { + "shot_id": 13, + "first_frame": "High-angle overhead shot of the workbench: the optical breadboard, sealed glass chamber, laptop with a smooth stable line, the handheld meter placed neatly, and the clipboard with a checked final box. Scientist A’s green sleeves and Scientist B’s navy sleeves enter from opposite sides as they sign and place a small label reading 'Run 3: Stable' (generic).", + "video_prompt": "Overhead high-angle shot with a 28mm lens. Scientist A writes a final note on the clipboard; Scientist B applies the small label to a sample log card and aligns it squarely. Both withdraw their hands, leaving the tidy, stable setup centered in frame as the shot ends on an organized, successful conclusion." + } + ] + } + ], + "metadata": { + "theme_key": "scientists_lab_collaboration", + "theme_description": "Two scientists collaborating on an experiment", + "consistency_type": "Type C", + "requested_scenes": 3, + "requested_shots": 13 + } +} diff --git a/vimax_benchmark/siblings_organizing_family_event_typeC.json b/vimax_benchmark/siblings_organizing_family_event_typeC.json new file mode 100644 index 0000000..433b379 --- /dev/null +++ b/vimax_benchmark/siblings_organizing_family_event_typeC.json @@ -0,0 +1,68 @@ +{ + "story_overview": "Two adult siblings collaborate to organize a family reunion, coordinating details at home and then successfully hosting the event as family members arrive and enjoy the gathering.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide, bright daytime kitchen-dining area in a cozy home. Character A (adult woman, 28 years old, tall and slim, medium-brown skin, dark curly hair in a high puff, thin round glasses, wearing a forest-green cardigan over a white crew-neck shirt and dark jeans, white sneakers) stands at a wooden dining table covered with a paper calendar, colored sticky notes, and a laptop. Character B (adult man, 32 years old, average height with a sturdy build, light skin, short dark hair, neatly trimmed beard, wearing a navy zip-up hoodie over a light-gray T-shirt and khaki chinos, brown casual shoes) stands opposite her holding a pen and a clipboard. Both look focused and friendly; no other people visible. Soft sunbeams through a window, houseplants on the sill, neutral decor.", + "video_prompt": "Eye-level wide shot, 24mm lens. Character A points to a date on the paper calendar while Character B nods and writes on the clipboard; they lean in toward the table together. Subtle handheld sway as the camera slowly pushes in, emphasizing teamwork and planning." + }, + { + "shot_id": 2, + "first_frame": "Over-the-shoulder view from behind Character B toward Character A at the dining table. The laptop screen shows a simple invite draft (no logos), and sticky notes are arranged in columns labeled 'Food', 'Games', 'Seating'. Character A’s thin round glasses catch a soft reflection; her green cardigan sleeves are rolled neatly to the wrist. Character B’s navy hoodie shoulder frames the foreground.", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens. Character A types briefly, then turns the laptop slightly toward Character B; Character B’s hand enters frame to point at a line on the screen. The camera holds steady with a slight micro-pan to keep their hands and the laptop centered." + }, + { + "shot_id": 3, + "first_frame": "Two-shot at the table from the side, medium framing. Character A holds up a printed checklist titled 'Reunion Essentials' while Character B holds a small cardboard box containing blank name tags and markers. On the table: a folded banner that reads 'Family Reunion' in generic lettering, a roll of tape, and a stack of paper plates. Warm indoor lighting; tidy background shelves.", + "video_prompt": "Eye-level medium two-shot, 35mm lens. Character A and Character B compare the checklist to the box contents; Character B gently sets the box down and slides it across the table to Character A. Character A smiles and taps the checklist with the pen, signaling the next step. Camera makes a slow lateral slide to the right for a fresh composition shift." + }, + { + "shot_id": 4, + "first_frame": "Close-up on hands at the table: Character A’s hands (with a simple watch) and Character B’s hands (no rings) assembling a paper centerpiece. A small bundle of string lights (unplugged) and a jar of pens sit nearby. The green cardigan cuff and navy hoodie cuff are visible, clearly separated. Background softly blurred.", + "video_prompt": "Tabletop close-up, 85mm lens. Their hands coordinate: Character B holds the folded paper steady while Character A ties a ribbon; they finish and set the centerpiece upright. The camera performs a gentle rack focus from the ribbon knot to the completed centerpiece, then back to their hands as they reach for the next item." + }, + { + "shot_id": 5, + "first_frame": "Exterior, late afternoon. A compact car is parked in a driveway beside a small house. Character A and Character B stand at the open trunk together. Character A holds a folded picnic blanket and a paper bag labeled 'Snacks' (generic). Character B holds a large, flat box containing the folded reunion banner. The sky is golden; trees sway lightly.", + "video_prompt": "Low angle medium-wide shot, 28mm lens. Character B carefully places the banner box into the trunk while Character A sets the picnic blanket on top, patting it to level the stack. They exchange an encouraging nod and close the trunk together. Camera tilts up slightly as the trunk shuts, ending on their determined expressions." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 6, + "first_frame": "Community center hall interior, early evening. Long folding tables form a U-shape with neutral tablecloths; a registration table is near the entrance with name tags and markers. A simple 'Family Reunion' banner hangs on the far wall. Character A stands on the left side of frame holding a roll of tape; Character B stands on the right holding a small step stool. Lighting is bright and even; polished floor reflects overhead lights.", + "video_prompt": "Eye-level wide shot, 24mm lens. Character B carries the step stool to the wall and sets it down; Character A walks alongside him, gesturing where the banner should align. They move in sync across the frame, reinforcing shared responsibility. Camera remains locked-off to emphasize spatial clarity as they cross the room." + }, + { + "shot_id": 7, + "first_frame": "Over-the-shoulder shot from behind Character A looking toward Character B near the registration table. Character B leans slightly over the table, arranging name tags in neat rows. Character A holds a clipboard with a guest list. In the background, a doorway reveals the hall entrance; a few indistinct adult relatives are beginning to appear as silhouettes at a distance (no detailed faces yet).", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens. Character A reads a name from the clipboard and points; Character B matches it to a name tag and sets it at the front. In the background, two adult relatives step into the hall and pause, looking around. The camera makes a small, controlled pan right to include the entrance activity, building anticipation." + }, + { + "shot_id": 8, + "first_frame": "Eye-level two-shot near the entrance, medium-wide framing. Character A (green cardigan, glasses) and Character B (navy hoodie, beard) stand behind the registration table. Two adult relatives (generic, modest casual clothing) stand in front of the table; one holds a tote bag. Character A offers a name tag; Character B holds a pen and gestures toward the seating area. The banner and decorated tables are visible behind them.", + "video_prompt": "Eye-level medium-wide two-shot, 35mm lens. Character A hands a name tag across the table to a relative; Character B points toward the U-shaped tables and then gives a welcoming wave. The relatives nod appreciatively and begin walking past the camera toward the seating area. The camera subtly dollies backward to keep all four adults in frame while maintaining clear separation of the siblings’ identities and outfits." + }, + { + "shot_id": 9, + "first_frame": "Climax: Wide shot of the community hall now lively but orderly and family-friendly. Adults sit and stand around the U-shaped tables chatting; a few hold paper cups. Character A and Character B stand together at the center aisle holding the ends of a simple ribbon (not a barrier, just ceremonial) with a small sign that reads 'Welcome, Family!' They smile with relief and pride. Bright, warm lighting; decorations modest and neat.", + "video_prompt": "Eye-level wide shot, 24mm lens. Character A and Character B raise the ribbon slightly and then lower it as if signaling the reunion has officially started; nearby relatives clap softly and turn toward them with smiles. The camera performs a slow, smooth push-in toward the siblings, ending on their satisfied expressions as ambient conversation continues in the background." + } + ] + } + ], + "metadata": { + "theme_key": "siblings_organizing_family_event", + "theme_description": "Siblings organizing a family reunion event", + "consistency_type": "Type C", + "requested_scenes": 2, + "requested_shots": 9 + } +} diff --git a/vimax_benchmark/teacher_student_tutoring_session_typeC.json b/vimax_benchmark/teacher_student_tutoring_session_typeC.json new file mode 100644 index 0000000..ec8b335 --- /dev/null +++ b/vimax_benchmark/teacher_student_tutoring_session_typeC.json @@ -0,0 +1,108 @@ +{ + "story_overview": "During an after-hours tutoring session, an adult teacher helps an adult student tackle a challenging problem. They work through confusion, test a solution on a whiteboard, and end with a confident breakthrough and a clear study plan.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Establishing two-shot in a quiet classroom at dusk: Character A (teacher), a 34-year-old woman with warm brown skin, dark curly hair in a neat low bun, rectangular black eyeglasses, wearing a forest-green cardigan over a white button-up shirt and charcoal slacks, stands beside a wooden desk. Character B (student), a 22-year-old man with light skin, short straight black hair, clean-shaven, wearing a navy crewneck sweater over a light-blue collared shirt and khaki pants, sits at the desk with an open notebook and a capped pen. A large whiteboard with faint grid lines is behind them; soft amber light from a window and overhead fluorescents mix gently. Both are modestly dressed; the mood is focused and calm.", + "video_prompt": "Eye-level wide two-shot from the front of the desk. The teacher sets a closed textbook and a folder onto the desk, then gestures toward the student’s notebook; the student nods and turns the notebook slightly toward her. Subtle classroom ambience, steady camera, hard cut at end." + }, + { + "shot_id": 2, + "first_frame": "Over-the-shoulder medium shot from behind the teacher’s right shoulder: the teacher’s forest-green cardigan sleeve and hand are in the foreground pointing at the student’s notebook. The student’s face is visible, attentive but slightly uncertain, pen in hand above a page filled with neatly written equations. Whiteboard and a wall clock blur in the background; lighting remains warm and even.", + "video_prompt": "Over-the-shoulder medium shot (teacher foreground, student midground). The teacher taps a specific line in the notebook with a capped marker, then slides a sticky note onto the page; the student follows with his eyes and lightly underlines the referenced step. Minor handheld micro-movement for realism." + }, + { + "shot_id": 3, + "first_frame": "Medium close-up on the student at the desk: Character B’s navy sweater and light-blue collar are crisp; he looks down at the notebook, brows knit in concentration. The teacher stands just out of focus to frame-left; her green cardigan edge and hand holding a dry-erase marker are visible. A tidy pencil cup and a calculator sit near the notebook.", + "video_prompt": "Eye-level medium close-up on the student. He attempts to write the next step, pauses, then looks up toward the teacher with a questioning expression; the teacher’s hand enters frame to indicate a different line, guiding him back. Shallow depth of field; gentle rack focus from his pencil tip to his eyes." + }, + { + "shot_id": 4, + "first_frame": "Side-angle medium two-shot: the teacher leans slightly (respectfully) over the desk without touching the student, pointing to the notebook; the student angles the notebook toward her. The whiteboard behind them shows a clean area ready for writing; chairs are stacked at the back of the room. Lighting is calm, with mild reflections on the whiteboard.", + "video_prompt": "Medium two-shot from a 45-degree side angle. The teacher offers the marker to the student; he takes it carefully, then both turn their gaze toward the whiteboard as if preparing to stand and work there. Camera remains static; hard cut." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Wide shot facing the whiteboard: the teacher (green cardigan, glasses) stands to the left of the board holding a marker cap; the student (navy sweater) stands to the right holding the marker, both adults. The board has a heading written neatly: “Practice Problem.” A small ledge holds extra markers and an eraser. Classroom desks sit in the foreground, slightly out of focus.", + "video_prompt": "Eye-level wide shot centered on the whiteboard. The student writes the first line of the problem; the teacher points to where to align terms, then steps half a pace back to give him space. The student pauses, reads aloud silently (no on-screen text), and continues writing." + }, + { + "shot_id": 6, + "first_frame": "Over-the-shoulder medium shot from behind the student: his navy sweater shoulder dominates foreground; the teacher is visible across the board, her glasses catching a soft reflection. The student’s writing on the whiteboard is legible in structure (symbols and lines), with a few terms circled by the teacher in a different color.", + "video_prompt": "Over-the-shoulder medium shot (student foreground, teacher midground). The teacher adds a small annotation beside a circled term, then hands the eraser across the ledge; the student accepts it and cleans one mistaken line, leaving the rest intact. Slight camera sway as if on a shoulder rig." + }, + { + "shot_id": 7, + "first_frame": "Close-up on hands at the whiteboard ledge: the teacher’s hand (green sleeve) passes a blue marker; the student’s hand (navy sleeve) receives it. The ledge holds two other markers, a felt eraser, and a small magnet. The background is softly blurred with whiteboard texture visible.", + "video_prompt": "Macro close-up with shallow depth of field at the whiteboard ledge. The marker transfer completes (one clean pass), then the student clicks the cap and raises the marker out of frame; the teacher’s hand steadies the eraser and slides it aside. Focus shifts from the marker tip to the teacher’s fingertips." + }, + { + "shot_id": 8, + "first_frame": "Medium two-shot near the whiteboard from slightly below eye level: the student points at a step he just wrote, looking unsure; the teacher listens, chin slightly tilted, calm expression. Their outfits remain consistent and distinct: green cardigan and glasses for the teacher; navy sweater for the student. The board shows a multi-step solution in progress.", + "video_prompt": "Slight low-angle medium two-shot. The student gestures at the questionable step; the teacher responds by tracing an invisible path in the air from one term to the next, then writes a short corrective note on the board. The student nods but still looks uncertain as the teacher finishes the note." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Wide shot from the back of the classroom toward the whiteboard: desks in the foreground, teacher and student midground at the board. The student stands closer to the board; the teacher stands slightly behind and to his left, maintaining a respectful distance. The classroom is orderly; evening light is dimmer, with overhead lights now more prominent.", + "video_prompt": "Back-of-room wide shot. The student tries to complete the next line, hesitates, and lowers the marker; the teacher steps forward and points to the earlier circled term, prompting him to reconsider. The student erases a small section and rewrites with more confidence." + }, + { + "shot_id": 10, + "first_frame": "Close-up on the teacher’s face and upper torso: Character A’s rectangular black glasses, calm focused eyes, and green cardigan are clear. She holds a marker and a small stack of sticky notes. The whiteboard is blurred behind her with lines of math faintly visible.", + "video_prompt": "Eye-level close-up on the teacher. She speaks silently with a reassuring expression, then demonstrates a small ‘check’ motion with the marker (tapping the air), and places a sticky note on the board edge labeled only by a simple drawn star (no readable text beyond a star shape). Subtle rack focus from her glasses to the marker." + }, + { + "shot_id": 11, + "first_frame": "Close-up on the student’s face and upper torso: Character B’s short black hair and navy sweater are crisp; he looks from the board to the teacher, then back to the board, visibly processing. A soft highlight from overhead lighting outlines his profile.", + "video_prompt": "Eye-level close-up on the student. He takes a steady breath, nods once, then raises the marker and turns slightly toward the board, determination replacing confusion. Camera holds steady; hard cut." + }, + { + "shot_id": 12, + "first_frame": "Tight medium shot on the whiteboard writing area between them: the student’s arm (navy sleeve) writes a clean line; the teacher’s hand (green sleeve) hovers nearby pointing at alignment. The board shows a nearly complete solution with one final step remaining; marker strokes are bold and tidy.", + "video_prompt": "Tight medium shot focused on the board and their hands. The student completes the penultimate step; the teacher briefly stops his motion with a gentle open-palm gesture (no contact), then redirects his marker to add a missing symbol. The student adjusts and the line looks correct; both hands withdraw slightly." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Medium two-shot facing them at the whiteboard: the student writes the final line while the teacher watches, smiling subtly. Their identities remain distinct: teacher in green cardigan with glasses; student in navy sweater. The board now has a boxed final answer, drawn by the student, with the teacher’s earlier circled annotations still present.", + "video_prompt": "Eye-level medium two-shot. The student finishes the final stroke, boxes the result, and steps half a pace back; the teacher gives a single approving nod and points to the boxed answer, then gestures toward the earlier steps to reinforce the method. Lighting feels slightly brighter as they lean back from the board." + }, + { + "shot_id": 14, + "first_frame": "Over-the-shoulder medium shot from behind the teacher toward the desk: the student sits again at the wooden desk, notebook open; the teacher stands beside him. The teacher places a simple checklist sheet (blank-looking with lines, no readable text) next to the notebook. A calculator and pen are neatly arranged.", + "video_prompt": "Over-the-shoulder medium shot (teacher foreground, student midground at desk). The teacher slides the checklist sheet forward; the student writes a short note in his notebook, then taps the page once with his pen as if confirming the plan. The teacher lightly points to two bullet-like lines on the sheet (no legible text)." + }, + { + "shot_id": 15, + "first_frame": "Final wide two-shot near the classroom door: the teacher holds her folder against her side; the student holds his notebook and a closed textbook. Both smile politely, relaxed. The whiteboard is visible in the background with the completed solution, and the room remains tidy and well-lit. Modest attire maintained; family-friendly tone.", + "video_prompt": "Eye-level wide two-shot by the doorway. The student offers a respectful handshake; the teacher returns it briefly, then the student gives a grateful nod. They turn slightly toward the exit together, walking one step out of frame as the shot ends. Hard cut end." + } + ] + } + ], + "metadata": { + "theme_key": "teacher_student_tutoring_session", + "theme_description": "A teacher and student having a tutoring session", + "consistency_type": "Type C", + "requested_scenes": 4, + "requested_shots": 15 + } +} diff --git a/vimax_benchmark/theater_rehearsal_community_stage_typeB.json b/vimax_benchmark/theater_rehearsal_community_stage_typeB.json new file mode 100644 index 0000000..83c4eac --- /dev/null +++ b/vimax_benchmark/theater_rehearsal_community_stage_typeB.json @@ -0,0 +1,113 @@ +{ + "story_overview": "In a community theater, three adult performers and a stage manager rehearse a short scene. They block entrances, adjust props, and solve a timing issue with a set piece. The rehearsal builds to a clean final run-through where everyone hits their marks under rehearsal lights.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Wide establishing view of a community theater stage from the center aisle of the empty auditorium. The indoor 3D environment is clearly defined and must remain identical in every shot: a proscenium arch with deep red curtains pulled to the sides; a painted backdrop of a cozy living room; stage left: a worn olive-green sofa angled slightly toward center, a standing floor lamp beside it, and a small side table with a closed notebook; center stage: a rectangular wooden coffee table with a taped-out mark on the floor in front of it; upstage center: a freestanding doorway flat (cream-colored frame) with a practical door; stage right: a tall bookshelf flat with scattered books, and a rolling costume rack behind it; downstage right: a black stage monitor and a coiled cable; overhead: visible lighting grid casting bright rehearsal work-lights. Three adults (20+), modest clothing: a stage manager in dark sweater and jeans holding a clipboard at downstage left; two performers in long-sleeve tops and slacks near center; a third performer near the doorway upstage.", + "video_prompt": "Eye-level wide shot from the center aisle. The camera slowly dollies forward toward the proscenium while the stage manager raises the clipboard to signal the start; the two center performers step into their taped marks near the coffee table, and the upstage performer reaches for the doorway handle, testing it gently. Rehearsal work-lights remain steady; the set geometry and prop placement do not change." + }, + { + "shot_id": 2, + "first_frame": "Medium shot from stage left looking diagonally across to stage right, emphasizing depth: the olive-green sofa dominates the foreground left; the coffee table sits mid-ground center; the bookshelf flat and costume rack are visible in the background right. The stage manager (adult, 20+) stands near the sofa arm, modest dark sweater, jeans, hair tied back, clipboard in hand. A performer (adult, 20+) in a light blue button-up shirt and dark slacks stands mid-ground by the coffee table. All set pieces remain exactly as established.", + "video_prompt": "Stage-left medium shot with a mild telephoto lens. The camera holds steady as the stage manager walks behind the sofa (partially occluded by its backrest), taps a floor tape mark with a pen, and points toward center. The performer by the coffee table nods and takes two small steps forward to align with the taped mark, stopping with feet together." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder shot from behind the performer in the light blue shirt, looking toward the stage manager near the sofa. The coffee table edge is visible bottom frame; the floor lamp and side table sit left; the doorway flat is visible upstage center. The stage manager’s clipboard catches the overhead rehearsal light.", + "video_prompt": "Over-the-shoulder medium shot, eye-level. The camera subtly pans right as the stage manager demonstrates the blocking with open palms; the performer in the foreground adjusts posture and turns slightly toward the doorway. Upstage, the third performer (adult, 20+) in a mustard cardigan and dark trousers waits by the door, hands relaxed at sides." + }, + { + "shot_id": 4, + "first_frame": "Low-angle close shot from downstage right near the black stage monitor and coiled cable, looking up toward center stage. The coffee table legs and taped floor marks are prominent. The performer in a cream sweater and dark skirt-like trousers (modest, ankle-length) crouches carefully near the coffee table, checking a small prop (a folded letter) placed on the table’s corner. The rest of the set remains fixed.", + "video_prompt": "Low-angle close shot from downstage right. The camera stays locked as the performer gently places the folded letter precisely on a tape-aligned spot on the coffee table, then stands, stepping backward toward the sofa. Their movement passes in front of the table, emphasizing depth; they avoid the coiled cable near the monitor." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Medium-wide shot from upstage center, facing downstage. The doorway flat frames the left edge of the image; the coffee table is center; the sofa is stage left; bookshelf flat stage right. The performer in the mustard cardigan stands at the doorway, hand on the handle, partially hidden by the doorframe (occlusion). The other two performers stand downstage near the coffee table, facing upstage. Lighting remains bright rehearsal work-lights.", + "video_prompt": "Upstage center medium-wide shot, slight high angle. The camera slowly tilts down as the doorway performer opens the door inward and steps through, briefly disappearing behind the door edge, then reappearing into the room set. Downstage performers shift their weight and turn their heads to track the entrance." + }, + { + "shot_id": 6, + "first_frame": "Two-shot at eye level near center stage, framed between the coffee table and the sofa. The performer in light blue shirt stands screen left; the performer in cream sweater stands screen right. The folded letter is visible on the coffee table. The sofa and lamp remain fixed in the left background.", + "video_prompt": "Eye-level medium two-shot with a normal lens. The camera gently pushes in as the two performers rehearse a silent exchange: the cream-sweater performer points to the letter, then slides it across the table toward the light-blue-shirt performer, who places a hand on it but does not pick it up yet. Their expressions are focused and calm, matching rehearsal concentration." + }, + { + "shot_id": 7, + "first_frame": "Side angle from stage right, looking across the bookshelf flat toward center. The bookshelf occupies the near foreground right, creating strong depth; the coffee table and sofa are mid-ground; the doorway is visible upstage. The mustard-cardigan performer walks from upstage center toward downstage, passing behind the coffee table line.", + "video_prompt": "Stage-right medium shot with slight telephoto. The camera tracks left a small amount as the mustard-cardigan performer crosses from the doorway toward the coffee table, moving behind the bookshelf edge for a moment (partial occlusion), then emerging fully into view. They stop at a taped mark near the table, toes aligned to the tape." + }, + { + "shot_id": 8, + "first_frame": "High-angle shot from the lighting grid perspective, looking straight down onto the stage layout. The fixed geometry is clear: sofa stage left, coffee table center, doorway upstage center, bookshelf stage right, costume rack behind it, monitor downstage right. Three performers form a triangle around the coffee table; the stage manager stands downstage left with clipboard.", + "video_prompt": "Overhead high-angle static shot. The stage manager steps forward two paces and uses the clipboard edge to indicate spacing; the performers adjust: one steps closer to the sofa, another pivots to face the doorway, and the third shifts a half-step back, keeping the triangle formation symmetrical around the coffee table." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Close shot on the stage manager at downstage left, with the sofa armrest in the near foreground and the floor lamp behind. The clipboard is open, showing taped notes. The stage manager (adult, 20+) wears a modest dark sweater and jeans, hair tied back, neutral expression focused on timing.", + "video_prompt": "Eye-level close-up with mild telephoto. The camera holds as the stage manager looks up from the clipboard, raises a hand for a count-in, then points toward the doorway. Off-screen, a performer cues a line silently (mouths words) as the manager nods once, signaling to continue." + }, + { + "shot_id": 10, + "first_frame": "Medium shot from center stage facing stage right. The rolling costume rack sits behind the bookshelf flat; the coiled cable and monitor are downstage right. The cream-sweater performer carefully walks toward downstage right, approaching the cable area. The stage manager is visible far left background near the sofa.", + "video_prompt": "Center-stage medium shot, slight low angle. The camera pans right as the performer approaches the coiled cable, pauses, then steps around it deliberately, demonstrating safe stage traffic. They stop beside the stage monitor, kneel briefly to straighten the cable loop without moving the monitor’s position, then stand." + }, + { + "shot_id": 11, + "first_frame": "Over-the-shoulder shot from behind the mustard-cardigan performer, facing the light-blue-shirt performer across the coffee table. The folded letter remains on the table. The doorway flat is visible upstage, unchanged. Both performers maintain modest attire and calm, rehearsal-focused expressions.", + "video_prompt": "Over-the-shoulder medium shot, eye-level. The camera slowly pushes in as the light-blue-shirt performer finally picks up the folded letter, opens it carefully, and scans it. The mustard-cardigan performer leans slightly forward with hands clasped, then relaxes back to neutral stance on the taped mark." + }, + { + "shot_id": 12, + "first_frame": "Wide shot from downstage center, showing the full set and all three performers plus the stage manager at the edge. The sofa, lamp, side table, coffee table, doorway, bookshelf, costume rack, monitor, and cable are all visible and unchanged. The performers prepare for a quick timing run: one at the doorway, two at the coffee table; stage manager watches.", + "video_prompt": "Downstage center wide shot, eye-level. The camera remains steady as the performers execute a rehearsal beat: the doorway performer steps in, the cream-sweater performer crosses behind the coffee table toward the sofa (briefly occluded by the table edge), and the light-blue-shirt performer turns to face downstage, marking a pause on the taped mark. The stage manager quietly counts time with small finger taps on the clipboard." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Medium-wide shot from stage left near the sofa, looking toward center and the doorway. The sofa backrest fills the lower left foreground; the coffee table is center; the doorway flat is upstage. The stage manager stands just off the sofa corner with clipboard. The performers reset positions for a final run-through.", + "video_prompt": "Stage-left medium-wide shot, eye-level. The camera glides slightly forward as the stage manager gives a clear start cue with a downward hand motion. The doorway performer places a hand on the door handle, the other two performers square their shoulders at the coffee table, and everyone holds for a beat, ready to begin cleanly." + }, + { + "shot_id": 14, + "first_frame": "Low-angle shot from near center front edge of the stage aimed upward at the performers around the coffee table, making the rehearsal lights and grid visible overhead. The coffee table and folded letter are prominent. The performers’ modest outfits remain consistent. The set remains fixed.", + "video_prompt": "Low-angle medium-wide shot with a wide lens. The camera holds as the doorway performer enters precisely on cue; the cream-sweater performer gestures toward the letter; the light-blue-shirt performer responds with a measured nod. Their movements are synchronized, hitting marks with clear spacing around the table." + }, + { + "shot_id": 15, + "first_frame": "Overhead shot from the same grid viewpoint as earlier, capturing the final blocking pattern: sofa stage left, coffee table center, doorway upstage center, bookshelf stage right. The three performers move in coordinated paths around the coffee table; the stage manager stands downstage left, watching.", + "video_prompt": "Overhead high-angle shot, slight slow zoom in. The performers complete the tricky cross: one passes behind the coffee table while another steps toward the sofa, creating layered depth; they avoid the monitor/cable zone downstage right. The stage manager lowers the clipboard slightly, indicating the timing issue is resolved." + }, + { + "shot_id": 16, + "first_frame": "Medium close shot from stage right, framed with the bookshelf flat in the near foreground right and the coffee table mid-ground. The light-blue-shirt performer stands center holding the folded letter closed; the cream-sweater performer stands near the sofa line; the mustard-cardigan performer pauses near the doorway. The stage manager is visible at frame edge, attentive. Lighting remains bright and even.", + "video_prompt": "Stage-right medium close shot with mild telephoto. The camera gently racks focus from the bookshelf edge to the light-blue-shirt performer as they place the letter back on the coffee table exactly, then all three performers freeze in their end positions for a clean finish. The stage manager gives a small approving nod to end the run, concluding the rehearsal climax." + } + ] + } + ], + "metadata": { + "theme_key": "theater_rehearsal_community_stage", + "theme_description": "A rehearsal in a community theater stage", + "consistency_type": "Type B", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/three_friends_surprise_planning_typeC.json b/vimax_benchmark/three_friends_surprise_planning_typeC.json new file mode 100644 index 0000000..7621a63 --- /dev/null +++ b/vimax_benchmark/three_friends_surprise_planning_typeC.json @@ -0,0 +1,108 @@ +{ + "story_overview": "Three adult friends secretly coordinate decorations, a cake, and a surprise entrance plan, then successfully surprise their fourth friend at a cozy community hall party.", + "consistency_type": "Type C", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Interior, quiet coffee shop corner booth in daytime. Three adults sit together around a small wooden table with a closed laptop and notebooks. Character A: a 28-year-old woman, tall and athletic build, medium-brown skin, curly dark hair in a high puff, wearing a mustard-yellow cardigan over a white crew-neck blouse and dark jeans, thin rectangular glasses. Character B: a 32-year-old man, average height, light skin, short dark hair, neatly trimmed beard, wearing a navy crewneck sweater over a light collared shirt and khaki pants. Character C: a 29-year-old woman, shorter, East Asian features, straight black bob haircut, wearing a forest-green blazer over a beige turtleneck and black slacks, a silver wristwatch. On the table: a sticky note that reads “SURPRISE” in block letters, pens, and a small plate of cookies. Soft window light, warm tones, family-friendly mood.", + "video_prompt": "Eye-level medium wide two-and-a-half-shot (all three in frame), 35mm lens. A leans in and slides the sticky note to the center; B opens a notebook and points to a handwritten checklist; C nods and taps the laptop lid, then quietly gestures toward the shop entrance as if confirming secrecy. Subtle handheld sway, ambient cafe movement in background, no other faces prominent." + }, + { + "shot_id": 2, + "first_frame": "Close-up on hands and tabletop in the same booth. A’s mustard cardigan sleeve and glasses chain visible near frame edge; B’s navy sweater cuff and watch; C’s green blazer cuff with silver wristwatch. The sticky note “SURPRISE” sits beside a simple printed calendar page. A pen hovers over the calendar. Warm daylight reflections on wood grain and ceramic mug.", + "video_prompt": "Top-down close-up, 50mm lens. B’s finger taps a date on the calendar; A writes a time in neat block letters; C slides a small list of supplies into the center. The hands briefly overlap as they coordinate, then separate cleanly, emphasizing distinct sleeves and accessories." + }, + { + "shot_id": 3, + "first_frame": "Over-the-shoulder view from behind B (navy sweater shoulder in foreground) looking at A and C across the table. A’s curly puff and mustard cardigan are clear; C’s green blazer and bob haircut are clear. The laptop is now open, showing a generic party-planning checklist UI (no brand logos), with bullet points: “Decor,” “Cake,” “Guest texts.” Soft bokeh of coffee shop behind them.", + "video_prompt": "Over-the-shoulder medium shot, 70mm lens. A scrolls the checklist with two fingers on the trackpad; C points to the “Cake” line and mimes a round shape; B’s shoulder subtly shifts as he leans in. A then gives a small thumbs-up toward C, confirming roles." + }, + { + "shot_id": 4, + "first_frame": "Exterior sidewalk outside the coffee shop, late afternoon. The three friends stand together near a community bulletin board with generic flyers (no real brands). A (mustard cardigan) holds her phone; B (navy sweater) holds a folded paper bag; C (green blazer) holds a small notebook. They form a tight huddle, looking alert but cheerful. Sunlight creates crisp shadows; parked bicycles in background.", + "video_prompt": "Eye-level medium wide shot, 28mm lens. C steps half a pace closer and quietly counts on her fingers; A nods and types on her phone; B looks left and right as if making sure the plan stays secret, then smiles and points toward the street as they agree to split up. They begin to turn in different directions by the end of the clip." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Interior craft store aisle under bright fluorescent lighting. Shelves packed with colorful paper lanterns, streamers, and balloons (all family-friendly). A (mustard cardigan, glasses) and C (green blazer, silver watch) stand side-by-side comparing two rolls of ribbon—one gold, one teal. B (navy sweater, beard) is slightly behind them pushing a small basket cart with tissue paper. The aisle perspective is deep with repeating shelves.", + "video_prompt": "Eye-level wide shot, 24mm lens. A lifts the gold ribbon toward the overhead light to check sheen; C shakes her head and points to teal, then holds it against a packet of neutral balloons. B rolls the basket cart forward between them, stopping neatly as A and C place selected items into the basket without mixing their clothing or positions." + }, + { + "shot_id": 6, + "first_frame": "Close-up at the craft store shelf. C’s hands (green blazer cuffs, silver watch) pick up a pack of letter stickers; A’s hands (mustard sleeve) hold a roll of tape. B’s navy sleeve enters frame holding a plain white card that reads “WELCOME” in simple marker. Colorful packaging surrounds them.", + "video_prompt": "Side-angle close-up, 85mm lens. C tests letter stickers by peeling one corner (no full removal), A offers the tape, and B slides the “WELCOME” card under the stickers to compare spacing. Their hands pass items carefully: A hands tape to B; B hands the card to C; C returns the sticker pack to A—clear, coordinated object passing." + }, + { + "shot_id": 7, + "first_frame": "Interior small neighborhood bakery counter, warmly lit with display case. Behind glass: frosted cupcakes and a plain round cake base (generic, no logos). B (navy sweater) stands at the counter speaking to a baker off-camera; A (mustard cardigan) points at the cake size with both hands; C (green blazer) holds a notepad open, pen ready. A small sign reads “Custom Cakes” in generic font.", + "video_prompt": "Eye-level medium shot, 35mm lens. B gestures politely with an open palm toward the cake base; A measures a circle in the air to indicate size; C writes notes quickly, then tilts the notepad toward B for confirmation. B nods and taps the notepad lightly with one finger as if approving the final wording." + }, + { + "shot_id": 8, + "first_frame": "Interior community hall entryway at dusk. Neutral walls, coat rack, stackable chairs, and a long folding table visible deeper inside. The three friends enter carrying supplies: A holds a bag of balloons; B carries a cake box carefully with both hands; C carries a box of streamers and a roll of teal ribbon. The hall is empty and quiet, overhead lights slightly dim.", + "video_prompt": "Low-angle wide shot, 20mm lens. The trio steps through the doorway together; B leads slowly to keep the cake steady; A shifts the balloon bag to avoid bumping the doorframe; C nudges the door closed with her shoulder, then points down the hall toward the main room. They move deeper, growing smaller in frame to emphasize depth." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Interior main room of the community hall, evening. A long folding table sits center-left with a plain tablecloth; stackable chairs line the right wall; a small stage platform is at the far end; two support pillars stand mid-room. A (mustard cardigan) kneels by the table tying balloon strings; B (navy sweater) stands on a stable step stool taping a banner to the wall; C (green blazer) is near a pillar winding teal ribbon around it. Warm overhead lighting with soft shadows.", + "video_prompt": "Eye-level wide shot, 24mm lens. B carefully presses tape and smooths the banner edge; A ties off two balloons and sets them to bob gently; C circles the pillar, ribbon wrapping around as she moves partially behind it (brief occlusion), then reappears to secure the ribbon end. The camera stays steady as all three actions happen in coordinated rhythm." + }, + { + "shot_id": 10, + "first_frame": "Over-the-shoulder shot from behind A (mustard cardigan shoulder in foreground) looking toward B on the step stool and C at the pillar. The banner is partially readable: “HAPPY DAY!” in generic letters. A holds a spool of string; B reaches to align the banner; C gives a small hand signal indicating ‘a little higher.’", + "video_prompt": "Over-the-shoulder medium shot, 50mm lens. A tosses the spool gently across the table toward C; C catches it with both hands without stepping out of frame. B adjusts the banner upward by an inch, then steps down one rung on the stool, checking alignment. A nods, keeping her distinct mustard sleeve prominent in the foreground." + }, + { + "shot_id": 11, + "first_frame": "Close-up of the cake table setup. A plain frosted cake sits on a stand; paper plates and napkins arranged neatly. C (green blazer cuffs, silver watch) places teal ribbon along the table edge; B’s navy sleeve sets down the cake box lid; A’s mustard sleeve places a small bowl of wrapped candies. The textures: matte frosting, glossy ribbon, crisp paper goods.", + "video_prompt": "Table-level close-up, 65mm lens. C smooths the ribbon with her palm; B slides the lid away and straightens the cake stand; A rotates the candy bowl slightly to center it. Their hands briefly meet at the table edge as they align items, then pull back, leaving a symmetrical arrangement." + }, + { + "shot_id": 12, + "first_frame": "Interior hall near the entryway, lights lowered slightly as if preparing for the surprise. The three friends huddle behind a tall folding screen near the doorway. A (mustard cardigan) holds her phone showing a generic text message thread; B (navy sweater) raises a finger to his lips in a friendly “quiet” gesture; C (green blazer) peeks around the screen edge toward the door. The screen creates a strong foreground occluder.", + "video_prompt": "Low-light eye-level medium shot, 35mm lens. A tilts the phone toward B and C, then dims the screen with her thumb; B slowly lowers his hand and points to a spot behind the screen for everyone to stand; C leans out to peek, then retreats behind the screen, keeping her blazer and watch clearly visible. The camera remains fixed, emphasizing the hiding place." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Interior hall doorway area, moments later. The door is slightly open, showing a sliver of corridor light. A, B, and C are hidden behind the folding screen at frame left; only partial glimpses of their distinct clothing appear: A’s mustard sleeve, B’s navy shoulder, C’s green blazer edge. A large bunch of balloons is tucked beside them. The main room beyond is decorated and softly lit.", + "video_prompt": "Static eye-level wide shot, 28mm lens. The door swings open a little wider (driven by someone entering off-camera), corridor light spills in. Behind the screen, A adjusts the balloon strings; B steadies the balloons with one hand; C raises two fingers in a countdown signal. Their movements are small and controlled to stay hidden." + }, + { + "shot_id": 14, + "first_frame": "Two-shot facing the three friends now stepping out from behind the screen into the decorated room. A (mustard cardigan, glasses) holds the balloon bunch; B (navy sweater, beard) holds a small party popper-style confetti tube that is clearly labeled 'paper confetti' and not explosive; C (green blazer, bob) holds the “WELCOME” card. All three are smiling, standing in a line with the decorated banner and table behind them.", + "video_prompt": "Eye-level medium wide shot, 35mm lens. The three step forward together and raise their items: A lifts balloons shoulder-high, C lifts the “WELCOME” card chest-high, and B twists the confetti tube downward so paper confetti flutters gently to the floor (light, non-messy effect). They look toward the doorway (off-camera) as if greeting the arriving friend." + }, + { + "shot_id": 15, + "first_frame": "Reverse angle from near the doorway looking into the hall. A, B, and C stand together near the center of the room with decorations fully visible: banner on wall, ribbon-wrapped pillar, cake table. The trio forms a clear group composition: A on left with balloons, B center with hands open in a welcoming gesture, C on right holding the card. Warm, celebratory lighting; clean, PG-rated atmosphere.", + "video_prompt": "Eye-level medium shot, 50mm lens. The three lean in slightly toward the camera as if addressing the entering friend, then relax into laughter. A gently bounces the balloon bunch; B gestures toward the cake table with an inviting sweep; C nods and points to the decorated banner. The clip ends on their shared, satisfied reaction, completing the surprise-party climax." + } + ] + } + ], + "metadata": { + "theme_key": "three_friends_surprise_planning", + "theme_description": "Three friends planning a surprise party", + "consistency_type": "Type C", + "requested_scenes": 4, + "requested_shots": 15 + } +} diff --git a/vimax_benchmark/wine_tasting_cellar_venue_typeB.json b/vimax_benchmark/wine_tasting_cellar_venue_typeB.json new file mode 100644 index 0000000..b9b83e5 --- /dev/null +++ b/vimax_benchmark/wine_tasting_cellar_venue_typeB.json @@ -0,0 +1,113 @@ +{ + "story_overview": "In an underground wine cellar during a public tasting event, adult staff and guests move through a fixed, richly detailed room while preparing, sampling, and culminating in a friendly toast after a featured bottle is unveiled and poured.", + "consistency_type": "Type B", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Establishing view of a complex underground wine cellar tasting room, architecture locked: arched brick ceiling, warm amber wall sconces, long oak tasting table centered in frame, four high wooden stools along the near side, a wrought-iron pillar slightly left of center, stacked wooden barrels lining the left wall, a tall metal wine rack on the right wall, a small service counter at the far end with a brass desk lamp and a neat row of empty wine glasses. Stone floor with subtle damp sheen. Several adult guests (20+), modest attire (button-down shirts, sweaters, slacks, ankle-length skirts), gather near the table; an adult sommelier stands by the service counter holding a clipboard.", + "video_prompt": "Eye-level wide shot, 24mm lens from the room entrance. Slow, gentle push-in toward the center oak table as guests step aside and approach their places; one guest walks behind the wrought-iron pillar (occlusion), while the sommelier at the far counter sets the clipboard down under the brass lamp. Warm, steady lighting; no geometry changes to the room." + }, + { + "shot_id": 2, + "first_frame": "Medium shot from the right side of the oak table, showing the fixed background: the left wall barrels, the central pillar, and the far service counter aligned the same. Two adult guests stand near the stools; an adult server in a dark vest and white shirt (modest, long sleeves) carries a tray of clean glasses.", + "video_prompt": "Eye-level medium shot, 50mm lens from the right aisle beside the table. The server walks from the far counter toward foreground, tray level; a guest pulls out a stool and sits (contact with stool), while another guest sidesteps behind the pillar, briefly disappearing, then reappearing on the other side. Camera stays locked; room layout remains constant." + }, + { + "shot_id": 3, + "first_frame": "Close-up on the service counter at the far end: brass lamp, corkscrew, folded linen, and a lineup of empty glasses. The fixed cellar details remain visible in bokeh: arched brick ceiling and right-wall wine rack. The adult sommelier (20+, neat hair, modest suit jacket) reaches for a dark bottle resting on the counter.", + "video_prompt": "Eye-level close-up, 85mm lens focused on hands and tools at the counter. The sommelier places the bottle upright, rotates it to face the room, and sets a corkscrew beside it; a guest passes behind in soft focus, moving from right rack area toward the table (depth and background continuity). Subtle lamp glow highlights glass reflections." + }, + { + "shot_id": 4, + "first_frame": "High angle view looking down onto the center oak table: place settings of empty wine glasses and small note cards. The four stools on the near side are clearly spaced; the iron pillar and left barrels remain in fixed positions. Adult guests begin to cluster around the table, hands resting on the tabletop.", + "video_prompt": "Overhead high-angle static shot, 35mm lens. Guests slide note cards into place and align glasses; one guest leans a forearm on the table edge (contact), another steps between stools and the pillar, briefly occluded by the pillar as they move to the far side. Warm light pools on wood grain; no change in furniture placement." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Two-shot at the center of the oak table: the adult sommelier stands on the far side facing two adult guests seated on stools. The fixed background includes the left barrels, right wine rack, and the service counter behind the sommelier. Clean glasses and note cards are arranged on the table.", + "video_prompt": "Eye-level medium two-shot, 40mm lens from the near side of the table. The sommelier gestures toward the bottle notes and points to the glass; guests nod and adjust their seated posture. A server crosses the background from left barrels to right rack, moving behind the sommelier (depth testing). Camera remains steady; room geometry unchanged." + }, + { + "shot_id": 6, + "first_frame": "Low angle shot near floor level, looking along the stone floor toward the table legs and stools. The iron pillar rises in frame; the barrel stacks on the left remain fixed. Adult guests’ shoes and pant hems move around the stools; modest clothing only.", + "video_prompt": "Low-angle wide shot, 28mm lens close to the stone floor. A guest pulls a stool backward (scrape implied, contact with floor), then sits; another guest walks behind the stool row toward the far side, briefly hidden by table legs and the pillar (occlusion). Lighting glints off the slightly damp stone texture; no background warping." + }, + { + "shot_id": 7, + "first_frame": "Close-up at the oak table surface: an adult server’s hands place a tasting mat and a glass in front of an adult guest. The fixed cellar environment is visible behind: iron pillar and barrels in soft focus, same positions.", + "video_prompt": "Eye-level close-up, 90mm lens on tabletop. The server sets down the glass, slides it a few centimeters to align with the note card, then withdraws hands; the guest’s hand steadies the mat (contact). In the background, another guest passes behind the pillar, partially obscured. Warm highlights reflect off the glass rim." + }, + { + "shot_id": 8, + "first_frame": "Wide shot from the left side of the room showing the long oak table diagonally. Left wall barrels dominate foreground; the iron pillar and right wall wine rack remain fixed. The adult sommelier stands near the far counter holding the featured bottle; guests sit and stand around the table.", + "video_prompt": "Wide angle, 24mm lens from near the left barrels. Slow pan from the barrels toward the table and far counter, keeping the same cellar layout. The sommelier walks from the counter to the table, passing in front of the pillar; a guest in the near right steps back to allow passage (foreground interaction). Camera pan is smooth; architecture remains consistent." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Medium close-up of the sommelier at the center of the oak table with the bottle and corkscrew. Guests’ hands and glasses ring the frame edges. The iron pillar is fixed just behind the sommelier’s left shoulder; barrels and rack remain in their established positions.", + "video_prompt": "Eye-level medium close-up, 65mm lens. The sommelier cuts the foil, inserts the corkscrew, and begins twisting; guests lean in slightly, glasses held steady on the table (contact). A server moves in the background behind the pillar, briefly disappearing then reappearing on the other side. Warm light emphasizes metal tool sheen." + }, + { + "shot_id": 10, + "first_frame": "Extreme close-up of the cork emerging from the bottle neck over the oak tabletop. Reflections of the amber sconces shimmer on the glass. Background is blurred but stable: brick ceiling tones and the fixed lamp glow from the far counter.", + "video_prompt": "Macro close-up, 100mm lens with shallow depth of field. The cork lifts slowly with a controlled pull; the sommelier pauses, then fully removes it and sets it on a small linen square. Subtle camera micro-movement only; maintain consistent warm lighting and stable environment." + }, + { + "shot_id": 11, + "first_frame": "Over-the-shoulder shot from behind the sommelier looking down the length of the oak table toward seated guests. Fixed features align: stools on near side, pillar slightly left, barrels left, wine rack right. Glasses are arranged in a neat line.", + "video_prompt": "Over-the-shoulder medium shot, 35mm lens. The sommelier tilts the bottle and pours a small amount into the first glass; then shifts one step to the next place setting, careful and steady. Guests slide their glasses forward a few centimeters (contact with table). A guest in mid-background stands and moves behind the pillar to the far side (occlusion and depth)." + }, + { + "shot_id": 12, + "first_frame": "Medium shot near the right wall wine rack, looking toward the table. The rack’s grid and bottle silhouettes remain fixed; the iron pillar still anchors the center depth. In foreground, an adult guest stands near the rack holding a glass; another guest sits at the table.", + "video_prompt": "Eye-level medium shot, 50mm lens from the right wall area. The standing guest steps away from the rack toward the table, passing behind a seated guest and partially occluded by chair backs; they gently set their glass down on the table and sit on an open stool (contact and physics). Camera remains locked; background geometry consistent." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Close-up on a seated adult guest’s hands at the table: they hold the glass by the stem above a tasting mat; note card and pencil beside it. Stable background blur shows the pillar and barrels in their fixed positions.", + "video_prompt": "Eye-level close-up, 85mm lens. The guest gently swirls the wine once, then brings the glass near their nose for a careful smell; their other hand taps a note on the card with the pencil. In soft focus, another guest walks behind the pillar toward the counter, briefly obscured. Warm highlights track across the liquid surface." + }, + { + "shot_id": 14, + "first_frame": "Wide shot from the far end near the service counter, looking back toward the entrance. The brass lamp and counter edge frame the foreground; the long oak table leads into the room; pillar, barrels, and rack are all in their established places. The sommelier stands at the table’s center addressing the group.", + "video_prompt": "Wide shot, 27mm lens from behind the service counter. Slow dolly forward along the table axis as the sommelier lifts the bottle label toward the group for a featured reveal; guests lean in and then relax back, some seated, some standing. A server crosses from right rack to left barrels behind the sommelier (depth and occlusion by pillar). No changes to set layout." + }, + { + "shot_id": 15, + "first_frame": "Medium group shot along the near side of the oak table: three adult guests (20+) in modest attire hold glasses at chest height; the sommelier stands opposite. The iron pillar is visible between two guests in the background; barrels and rack remain fixed.", + "video_prompt": "Eye-level medium group shot, 45mm lens. The sommelier invites a toast; guests raise their glasses together, lean slightly toward the center, and clink once (single, gentle contact), then smile and lower glasses. One guest steps behind the pillar to make room, briefly occluded. Lighting remains warm and celebratory, PG-rated." + }, + { + "shot_id": 16, + "first_frame": "Final establishing angle: a serene wide shot from a corner near the left barrels, showing the entire stable cellar room—arched brick ceiling, amber sconces, iron pillar, long oak table, stools, right wine rack, and far service counter with brass lamp. Guests and staff are settling, glasses on the table, note cards visible.", + "video_prompt": "Wide angle static shot, 24mm lens from the left corner by the barrels. Gentle ambient motion: guests take their seats, a server collects an empty tray and walks behind the pillar toward the counter (occlusion), and the sommelier sets the bottle down at center table. The scene holds on the fixed architecture and cozy atmosphere to close the event." + } + ] + } + ], + "metadata": { + "theme_key": "wine_tasting_cellar_venue", + "theme_description": "A wine tasting event in an underground cellar", + "consistency_type": "Type B", + "requested_scenes": 4, + "requested_shots": 16 + } +} diff --git a/vimax_benchmark/writer_contrasting_locations_typeA.json b/vimax_benchmark/writer_contrasting_locations_typeA.json new file mode 100644 index 0000000..9f3b915 --- /dev/null +++ b/vimax_benchmark/writer_contrasting_locations_typeA.json @@ -0,0 +1,113 @@ +{ + "story_overview": "An adult writer struggles with a stalled draft, then deliberately seeks inspiration by working through radically different environments—quiet, chaotic, natural, and futuristic—until a final breakthrough arrives and the story clicks into place.", + "consistency_type": "Type A", + "scenes": [ + { + "scene_num": 1, + "shots": [ + { + "shot_id": 1, + "first_frame": "Interior, small apartment writing nook at dawn. A 34-year-old woman writer with warm brown skin, short tightly-curled black hair, and a small crescent-shaped scar above her right eyebrow sits at a simple oak desk. She wears a forest-green chunky knit sweater, straight-leg dark indigo jeans, and clean white low-top sneakers; a thin silver ring on her left hand. A matte-black laptop is open with a blank document; a ceramic mug of tea steams beside a yellow notepad and a black gel pen. Soft window light, pale blue shadows, quiet and cozy mood, shallow depth of field focused on her face and the blank screen.", + "video_prompt": "Eye-level medium shot, 50mm lens from across the desk. She exhales, taps the pen against the notepad, and stares at the blank laptop screen; her left hand (silver ring visible) hovers over the keyboard but doesn’t type. Steam curls from the mug as she glances toward the window, then back to the screen, showing frustration without speaking." + }, + { + "shot_id": 2, + "first_frame": "Same writer in the apartment entryway, morning light brighter. She stands near a coat rack and a small mirror. She still wears the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible. A canvas tote bag hangs from her shoulder; she holds the yellow notepad and black gel pen. The background includes a closed door with a simple peephole and a small potted plant on a shelf. Crisp, practical mood, slightly wider framing.", + "video_prompt": "Wide shot, 24mm lens, slightly high angle from the corner of the entryway. She slips the notepad into the tote, checks the black gel pen, and adjusts the tote strap. She pauses, touches the crescent scar above her right eyebrow as if thinking, then nods with resolve and opens the door to leave; the door swings outward into bright hallway light." + }, + { + "shot_id": 3, + "first_frame": "Exterior, rooftop garden on a city building in late morning. The same 34-year-old woman writer (crescent scar above right eyebrow, short tightly-curled black hair) sits on a wooden bench among raised planters filled with herbs and small flowers. She wears the same forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring on left hand. She holds her yellow notepad on her lap and the black gel pen poised. Sunlight is strong and clean; wind gently ruffles leaves. Distant skyline and blue sky form the backdrop.", + "video_prompt": "Low angle medium-wide shot, 28mm lens from near the planter edge. A breeze moves the plants as she starts writing a few lines, then pauses to look up at the skyline. She smiles faintly, underlines something on the notepad, and flips to the next page, energized by the open air." + }, + { + "shot_id": 4, + "first_frame": "Exterior, busy indoor-outdoor city market at midday with colorful stalls, baskets of produce, and hanging string lights. The same writer stands at the edge of a walkway, still in forest-green chunky knit sweater, dark indigo jeans, and white sneakers; tote bag at her side, silver ring visible. She holds her yellow notepad and black gel pen. The environment is bustling but family-friendly: vendors arranging fruit, people walking by with shopping bags, warm sunlight filtering through awnings. She watches, attentive, as if collecting details.", + "video_prompt": "Handheld-feel eye-level wide shot, 24mm lens from a few steps away. She steps aside to avoid passersby, quickly jots sensory notes, and looks from one stall to another. The camera subtly pans with her as she turns, tracking her gaze across colors and movement; she circles a word on the notepad and tucks a loose curl behind her ear." + } + ] + }, + { + "scene_num": 2, + "shots": [ + { + "shot_id": 5, + "first_frame": "Interior, quiet museum gallery in the afternoon. White walls, polished wood floor, soft spotlights on abstract paintings and a sculpture on a plinth. The same 34-year-old woman writer (crescent scar above right eyebrow) stands near a bench, wearing the same forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible. She holds the yellow notepad and black gel pen close to her chest. The mood is hushed, contemplative, with gentle warm lighting and crisp shadows.", + "video_prompt": "Over-the-shoulder medium shot, 35mm lens aimed toward the artwork with her shoulder and notepad in frame. She leans slightly forward to study brushstrokes, then looks down and writes a short line. She steps to the side, aligning herself with another painting; the camera makes a small lateral move to keep her centered as she compares shapes and colors." + }, + { + "shot_id": 6, + "first_frame": "Interior, underground subway platform at rush hour, evening. Fluorescent lighting, tiled walls, electronic signboards, and a train arriving in the background. The same writer stands behind the safety line, still in forest-green chunky knit sweater, dark indigo jeans, and white sneakers; tote bag strap across her shoulder, silver ring visible. She holds her notepad open with one hand and the black gel pen in the other. The scene is lively but safe: commuters in coats, no pushing, just movement and sound implied.", + "video_prompt": "Eye-level medium-wide shot, 28mm lens with slight telephoto compression. The train rolls in; wind from the arriving cars lightly flutters her sweater and notepad pages. She braces the notepad against her tote, quickly writes a fragment, then pauses to watch reflections in the train windows as doors open and people step out; the camera tilts gently to follow her gaze." + }, + { + "shot_id": 7, + "first_frame": "Exterior, rainy bus stop at dusk. Wet pavement reflecting streetlights, a glass shelter with raindrops beading on panels. The same writer sits on the bench inside the shelter, wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible. Her tote bag rests beside her. She holds the yellow notepad on her knees and writes with the black gel pen. Umbrellas and blurred car headlights pass in the background; mood is introspective, cinematic reflections.", + "video_prompt": "Close-up, 85mm lens focusing on her hands and notepad, shallow depth of field. Raindrops streak the glass behind her as she writes, pauses, then draws a small arrow and rewrites a sentence. She rubs her thumb over the silver ring thoughtfully, then resumes writing with more confidence; the camera slowly racks focus from the pen tip to her concentrated eyes." + }, + { + "shot_id": 8, + "first_frame": "Interior, cozy all-night diner at night. Warm amber pendant lights, checkered floor, chrome accents, and a window showing blurred neon from outside. The same writer sits in a booth, still wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible as she rests her left hand near a water glass. Her matte-black laptop is open now, and the yellow notepad sits beside it. A small plate with a simple sandwich and a napkin is on the table. Mood: inviting, focused, late-night creativity.", + "video_prompt": "Three-quarter angle medium shot, 35mm lens from across the booth. She types a few lines, stops, checks the notepad, then types again faster. She takes a small sip of water, nods, and highlights a line on the screen (implied by her hand motion and focus). The camera gently pushes in as her face brightens with a new direction." + } + ] + }, + { + "scene_num": 3, + "shots": [ + { + "shot_id": 9, + "first_frame": "Exterior, windswept beach at sunrise. Pale pink and orange sky, gentle waves, scattered smooth stones. The same 34-year-old woman writer stands near the shoreline, wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible. She holds the yellow notepad against the breeze with the black gel pen ready. The mood is expansive and calm; sunlight glints on wet sand.", + "video_prompt": "Wide shot, 20mm lens, low angle near the sand looking slightly upward. She walks slowly along the waterline, stopping to write a line while waves approach and retreat. The wind lifts a few pages; she presses them down with her left hand (ring catching the sunrise), then looks out over the horizon, breathing in, and writes again with renewed steadiness." + }, + { + "shot_id": 10, + "first_frame": "Exterior, mountain lookout in bright midday sun. Rocky foreground, distant peaks, crisp blue sky. The same writer sits on a flat rock, still in forest-green chunky knit sweater, dark indigo jeans, and white sneakers; tote bag beside her. Silver ring visible as she props the notepad on her knee. The light is intense with sharp shadows; the mood is bold and clarifying.", + "video_prompt": "High angle medium shot, 35mm lens from slightly above and behind her. She sketches a quick outline on the notepad, taps the pen twice, and draws a connecting line between points. She turns her head to scan the peaks, then flips the page decisively. The camera makes a short, smooth arc around her right side to reveal the sweeping landscape as she continues writing." + }, + { + "shot_id": 11, + "first_frame": "Exterior, dense forest trail under dappled light, afternoon. Ferns, mossy logs, tall trees, soft sunbeams cutting through leaves. The same writer walks along a narrow path, wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible. She holds the notepad in one hand and the pen in the other, careful but curious. The mood is intimate, textural, and grounded.", + "video_prompt": "Steadicam-style tracking shot, 24mm lens, eye-level following a few steps ahead facing her. She walks toward camera, slows beside a mossy log, and crouches slightly to observe details (staying fully modest and practical). She writes a quick phrase, then stands and continues forward; the camera retreats smoothly to maintain distance as sunbeams shift across her face and sweater knit texture." + }, + { + "shot_id": 12, + "first_frame": "Exterior, snow-covered town square at twilight. Soft snowfall, warm lights in shop windows, a decorated evergreen tree (seasonal, non-denominational) in the center. The same writer stands near a bench, wearing the same forest-green chunky knit sweater, dark indigo jeans, and white sneakers; she appears comfortable and composed. Silver ring visible; tote bag tucked under her arm, notepad open. Cool blue ambient light contrasts with warm window glow. Mood: serene, reflective.", + "video_prompt": "Medium-wide shot, 40mm lens, eye-level with gentle lateral slider move. Snow drifts down as she writes slowly, then pauses and looks at the glowing windows, as if hearing a line in her head. She smiles, closes the notepad with a soft pat, then reopens it to add one more sentence; the camera slides past a lit lamppost to frame her against the warm storefront bokeh." + } + ] + }, + { + "scene_num": 4, + "shots": [ + { + "shot_id": 13, + "first_frame": "Interior, ultra-modern coworking studio at night with cool white LED strips, glass partitions, minimalist desks, and a city skyline visible through tall windows. The same 34-year-old woman writer sits at a sleek desk, still wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible. Her matte-black laptop is open; the yellow notepad and black gel pen are neatly aligned beside it. Mood: crisp, futuristic focus; reflections on glass surfaces.", + "video_prompt": "Eye-level medium shot, 50mm lens from the side, framing her profile and the laptop glow. She types in concentrated bursts, pauses to glance at a page of notes, then resumes faster. The camera makes a slow push-in as LED reflections slide across the glass wall behind her, emphasizing momentum and clarity." + }, + { + "shot_id": 14, + "first_frame": "Interior, planetarium auditorium in near-darkness. A domed ceiling displays soft star projections; rows of seats are mostly empty. The same writer sits alone near the middle row, wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible as she holds the notepad open on her lap. The pen rests in her right hand. The starfield glow casts gentle, cool highlights across her face and sweater texture. Mood: awe and quiet revelation.", + "video_prompt": "Low-light close-up, 85mm lens, angled slightly upward from the seat in front of her. Star projections drift slowly across her features as she reads what she’s written, then adds a final line with deliberate care. She stops, looks up at the moving stars, and her expression shifts to certainty; the camera holds steady as the light pattern glides over her crescent eyebrow scar, reinforcing identity." + }, + { + "shot_id": 15, + "first_frame": "Exterior, quiet hillside overlook just before dawn. The sky is deep indigo transitioning to faint gold; a distant city glows below. The same writer stands at a wooden railing, wearing the forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring visible as she grips the railing. Tote bag on her shoulder; notepad tucked under her arm. Mood: climactic calm, breath of fresh air, poised to finish.", + "video_prompt": "Wide shot, 24mm lens, rear three-quarter view from behind her left side. She takes out the notepad, flips to a marked page, and nods as if the structure is complete. She turns slightly toward the rising light and presses the notepad to her chest for a beat, then looks down toward the city, ready to commit the final version; the camera slowly cranes up a little to widen the horizon." + }, + { + "shot_id": 16, + "first_frame": "Interior, back at her apartment writing nook in early morning, mirroring the first scene but brighter and warmer. The same 34-year-old woman writer (warm brown skin, short tightly-curled black hair, small crescent scar above right eyebrow) sits at the oak desk wearing the exact forest-green chunky knit sweater, dark indigo jeans, and white sneakers; silver ring on left hand. The matte-black laptop now shows a filled page of text (not readable), and the yellow notepad is open with multiple pages turned. A fresh mug of tea steams. Mood: satisfied, hopeful, gentle sunlight across the desk.", + "video_prompt": "Eye-level close-medium shot, 50mm lens from just off the laptop’s corner. She types the final lines, then stops and rests both hands lightly on the keyboard, exhaling with relief. She closes the laptop calmly, sets the pen atop the notepad, and looks toward the window with a quiet smile. The camera holds on her steady, confident posture as morning light brightens the room, signaling completion." + } + ] + } + ], + "metadata": { + "theme_key": "writer_contrasting_locations", + "theme_description": "A writer finding inspiration in contrasting locations", + "consistency_type": "Type A", + "requested_scenes": 4, + "requested_shots": 16 + } +}