From e06f56d26a9270257a2e895bf000db70aeff7eb9 Mon Sep 17 00:00:00 2001 From: Yiyabo Date: Fri, 10 Jul 2026 10:50:43 +0800 Subject: [PATCH 01/10] =?UTF-8?q?jimeng:=20add=20Volcengine=20Jimeng=20(?= =?UTF-8?q?=E5=8D=B3=E6=A2=A6=20AI)=20video=20provider=20with=20V4=20signi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Jimeng/Volcengine part of issue #249, as agreed with @xucailiang (who is handling the Kling provider separately). Adds a first-class Jimeng video provider that calls the Volcengine visual API directly (visual.volcengineapi.com) using HMAC-SHA256 V4 request signing with IAM AK/SK credentials. This is the first provider in OpenMontage to use V4 signing (all others use Bearer token auth). API flow: POST CVSync2AsyncSubmitTask -> poll CVSync2AsyncGetResult -> download video_url. Features: - Text-to-video and image-to-video (Jimeng 3.0 Pro) - Configurable frame count (121=5s, 241=10s at 24fps) - Aspect ratio selection (16:9, 9:16, 1:1, etc.) - Seed for reproducibility - Full V4 HMAC-SHA256 request signing (not Bearer token) - Error handling with Jimeng code 10000 success convention - API key redaction in error messages (both env vars, no empty-string bug) Env vars: VOLC_ACCESSKEY + VOLC_SECRETKEY (IAM AK/SK pair). Idempotency keys include all output-affecting fields. Files: - tools/video/jimeng_video.py — new tool (V4 signing + submit/poll/download) - tests/contracts/test_jimeng_video.py — 46 contract tests (no AK/SK needed) - .env.example — VOLC_ACCESSKEY + VOLC_SECRETKEY - docs/PROVIDERS.md — Volcengine Jimeng provider section End-to-end tested with real Volcengine IAM credentials: generated a 1920x1088 H.264 5.04s video, ffprobe verified. Test results: python -m pytest tests/contracts/test_jimeng_video.py -q # 46 passed --- .env.example | 2 + docs/PROVIDERS.md | 38 +++ tests/contracts/test_jimeng_video.py | 320 ++++++++++++++++++++++ tools/video/jimeng_video.py | 394 +++++++++++++++++++++++++++ 4 files changed, 754 insertions(+) create mode 100644 tests/contracts/test_jimeng_video.py create mode 100644 tools/video/jimeng_video.py diff --git a/.env.example b/.env.example index 73e7122b..a15bea60 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ SUNO_API_KEY= # Suno AI music generation (full songs, instrumenta # --- Video Generation --- HEYGEN_API_KEY= # HeyGen API (VEO, Sora, Runway, Kling, Seedance via single key) RUNWAY_API_KEY= # Runway Gen-4 (direct API, alternative to fal.ai routing) +VOLC_ACCESSKEY= # Volcengine Jimeng (即梦 AI) video generation via official API (HMAC-SHA256 V4 signing) +VOLC_SECRETKEY= # Secret Access Key paired with VOLC_ACCESSKEY. Get both at https://console.volcengine.com/iam/keymanage VIDEO_GEN_LOCAL_ENABLED= # Set to "true" for local video gen (needs GPU + diffusers) VIDEO_GEN_LOCAL_MODEL= # Local model: wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b MODAL_LTX2_ENDPOINT_URL= # Modal self-hosted LTX-2 endpoint (optional) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f5e34f7e..0dc38b18 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -95,6 +95,44 @@ OpenMontage now uses those published rates in the Grok tool estimators. --- +### Volcengine Jimeng — 即梦 AI Video Generation + +> **Direct ByteDance API via V4 signing.** Calls the Volcengine visual API (visual.volcengineapi.com) with HMAC-SHA256 request signing using IAM AK/SK credentials. Supports text-to-video and image-to-video via Jimeng 3.0 Pro. + +**Tools unlocked:** `jimeng_video` +**Env vars:** `VOLC_ACCESSKEY` (Access Key ID) + `VOLC_SECRETKEY` (Secret Access Key) + +#### Setup + +1. Go to [console.volcengine.com/iam/keymanage](https://console.volcengine.com/iam/keymanage) +2. Create a Volcengine account if you don't have one +3. Create an Access Key pair (AK + SK) +4. Ensure your account has access to Jimeng AI (即梦) video generation service +5. Add to `.env`: `VOLC_ACCESSKEY=...` and `VOLC_SECRETKEY=...` + +#### What it's best for + +- Direct ByteDance/Volcengine API quota usage +- Jimeng 3.0 Pro text-to-video and image-to-video +- Chinese-language prompt understanding +- Configurable frame count (121=5s, 241=10s) and aspect ratio + +#### API notes + +Authentication uses Volcengine IAM V4 signing (HMAC-SHA256), not a Bearer token. The signing process builds a canonical request, derives a signing key from SK → date → region → service, and signs the request. + +API flow: `POST ?Action=CVSync2AsyncSubmitTask` → poll `POST ?Action=CVSync2AsyncGetResult` → download `video_url`. + +The `req_key` for video is `jimeng_ti2v_v30_pro`. Success code is `10000`. Task statuses: `in_queue`, `generating`, `done`, `not_found`, `expired`. + +#### Pricing + +| Model | Price | +|------|-------| +| Jimeng 3.0 Pro (video) | ~$0.05/sec (check Volcengine console for actual rate) | + +--- + ### Alibaba DashScope — Qwen Image + TTS + ASR > **Best for Chinese-language production.** One key unlocks Qwen-Image generation, Qwen-TTS Mandarin narration, and Qwen-ASR with word-level timestamps — the only DashScope path that provides word-level granularity for subtitle alignment. diff --git a/tests/contracts/test_jimeng_video.py b/tests/contracts/test_jimeng_video.py new file mode 100644 index 00000000..9cd6d124 --- /dev/null +++ b/tests/contracts/test_jimeng_video.py @@ -0,0 +1,320 @@ +"""Contract tests for the Volcengine Jimeng video provider tool. + +These tests verify that the tool satisfies the BaseTool contract without +requiring real Volcengine AK/SK credentials or making any API calls. + +Run: pytest tests/contracts/test_jimeng_video.py -v +""" + +import pytest + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) +from tools.video.jimeng_video import JimengVideo + + +# ------------------------------------------------------------------ +# Contract compliance +# ------------------------------------------------------------------ + +class TestContract: + + def test_inherits_base_tool(self): + assert issubclass(JimengVideo, BaseTool) + + def test_has_required_identity(self): + tool = JimengVideo() + assert tool.name == "jimeng_video" + assert tool.version + assert tool.provider == "volcengine" + assert tool.capability == "video_generation" + assert tool.tier == ToolTier.GENERATE + assert tool.stability == ToolStability.EXPERIMENTAL + assert tool.runtime == ToolRuntime.API + + def test_execution_mode_is_async(self): + assert JimengVideo().execution_mode == ExecutionMode.ASYNC + + def test_has_input_schema(self): + schema = JimengVideo().input_schema + assert schema.get("type") == "object" + props = schema.get("properties", {}) + required = schema.get("required", []) + assert required == ["prompt"] + for field in required: + assert field in props + + def test_has_capabilities(self): + tool = JimengVideo() + assert "text_to_video" in tool.capabilities + assert "image_to_video" in tool.capabilities + + def test_has_agent_skills(self): + assert "ai-video-gen" in JimengVideo().agent_skills + + def test_has_fallbacks(self): + tool = JimengVideo() + assert "minimax_tokenplan_video" in tool.fallback_tools + assert "kling_video" in tool.fallback_tools + + def test_has_install_instructions(self): + tool = JimengVideo() + assert "VOLC_ACCESSKEY" in tool.install_instructions + assert "VOLC_SECRETKEY" in tool.install_instructions + + def test_get_info_returns_dict(self): + info = JimengVideo().get_info() + assert isinstance(info, dict) + assert info["name"] == "jimeng_video" + assert info["provider"] == "volcengine" + assert info["runtime"] == "api" + + def test_status_unavailable_without_keys(self, monkeypatch): + monkeypatch.delenv("VOLC_ACCESSKEY", raising=False) + monkeypatch.delenv("VOLC_SECRETKEY", raising=False) + assert JimengVideo().get_status() == ToolStatus.UNAVAILABLE + + def test_status_available_with_keys(self, monkeypatch): + monkeypatch.setenv("VOLC_ACCESSKEY", "fake-ak") + monkeypatch.setenv("VOLC_SECRETKEY", "fake-sk") + assert JimengVideo().get_status() == ToolStatus.AVAILABLE + + def test_status_unavailable_with_only_ak(self, monkeypatch): + monkeypatch.setenv("VOLC_ACCESSKEY", "fake-ak") + monkeypatch.delenv("VOLC_SECRETKEY", raising=False) + assert JimengVideo().get_status() == ToolStatus.UNAVAILABLE + + def test_has_resource_profile(self): + rp = JimengVideo().resource_profile + assert rp.network_required is True + assert rp.vram_mb == 0 + + def test_has_retry_policy(self): + assert JimengVideo().retry_policy.max_retries >= 0 + + def test_has_side_effects(self): + side = JimengVideo().side_effects + assert len(side) > 0 + assert any("API" in s for s in side) + + def test_has_user_visible_verification(self): + assert len(JimengVideo().user_visible_verification) > 0 + + def test_lazy_imports_requests(self): + import importlib + import sys + mod_name = "tools.video.jimeng_video" + if "requests" in sys.modules: + del sys.modules["requests"] + importlib.reload(sys.modules[mod_name]) + + def test_estimate_cost_returns_float(self): + cost = JimengVideo().estimate_cost({"prompt": "x", "frames": 121}) + assert isinstance(cost, float) + assert cost > 0.0 + + def test_dry_run_returns_dict(self): + result = JimengVideo().dry_run({"prompt": "test"}) + assert isinstance(result, dict) + assert result["tool"] == "jimeng_video" + + +# ------------------------------------------------------------------ +# Idempotency keys +# ------------------------------------------------------------------ + +class TestIdempotencyKeys: + + def test_includes_all_output_affecting_fields(self): + fields = JimengVideo().idempotency_key_fields + for field in ("prompt", "operation", "image_url", "frames", "aspect_ratio", "seed"): + assert field in fields, f"missing idempotency field: {field}" + + def test_excludes_execution_only_fields(self): + fields = JimengVideo().idempotency_key_fields + for field in ("output_path", "poll_interval_seconds", "timeout_seconds"): + assert field not in fields + + def test_differs_on_frames(self): + tool = JimengVideo() + base = {"prompt": "x"} + assert tool.idempotency_key(base) != tool.idempotency_key({**base, "frames": 241}) + + def test_differs_on_aspect_ratio(self): + tool = JimengVideo() + base = {"prompt": "x"} + assert tool.idempotency_key({**base, "aspect_ratio": "16:9"}) != tool.idempotency_key( + {**base, "aspect_ratio": "9:16"} + ) + + def test_differs_on_seed(self): + tool = JimengVideo() + base = {"prompt": "x"} + assert tool.idempotency_key({**base, "seed": -1}) != tool.idempotency_key( + {**base, "seed": 42} + ) + + def test_differs_on_image_url(self): + tool = JimengVideo() + base = {"prompt": "x", "operation": "image_to_video"} + assert tool.idempotency_key(base) != tool.idempotency_key( + {**base, "image_url": "https://example.com/img.png"} + ) + + +# ------------------------------------------------------------------ +# Tool-specific behavior +# ------------------------------------------------------------------ + +class TestToolSpecific: + + def test_default_frames_is_121(self): + tool = JimengVideo() + assert tool.input_schema["properties"]["frames"]["default"] == 121 + + def test_default_aspect_ratio_is_16_9(self): + tool = JimengVideo() + assert tool.input_schema["properties"]["aspect_ratio"]["default"] == "16:9" + + def test_default_seed_is_negative_one(self): + tool = JimengVideo() + assert tool.input_schema["properties"]["seed"]["default"] == -1 + + def test_cost_scales_with_frames(self): + tool = JimengVideo() + cost_5s = tool.estimate_cost({"prompt": "x", "frames": 121}) + cost_10s = tool.estimate_cost({"prompt": "x", "frames": 241}) + assert cost_10s > cost_5s + + def test_build_payload_t2v(self): + tool = JimengVideo() + payload = tool._build_payload({"prompt": "a cat"}) + assert payload["req_key"] == "jimeng_ti2v_v30_pro" + assert payload["prompt"] == "a cat" + assert payload["frames"] == 121 + assert payload["aspect_ratio"] == "16:9" + assert payload["seed"] == -1 + assert "image_urls" not in payload + + def test_build_payload_i2v_includes_image(self): + tool = JimengVideo() + payload = tool._build_payload({ + "prompt": "motion", + "operation": "image_to_video", + "image_url": "https://example.com/img.png", + }) + assert payload["image_urls"] == ["https://example.com/img.png"] + + def test_build_payload_t2v_omits_image(self): + tool = JimengVideo() + payload = tool._build_payload({"prompt": "a cat", "operation": "text_to_video"}) + assert "image_urls" not in payload + + def test_i2v_without_image_fails(self, monkeypatch): + monkeypatch.setenv("VOLC_ACCESSKEY", "fake-ak") + monkeypatch.setenv("VOLC_SECRETKEY", "fake-sk") + result = JimengVideo().execute({"prompt": "test", "operation": "image_to_video"}) + assert result.success is False + assert "image_url" in result.error + + def test_no_keys_returns_error(self, monkeypatch): + monkeypatch.delenv("VOLC_ACCESSKEY", raising=False) + monkeypatch.delenv("VOLC_SECRETKEY", raising=False) + result = JimengVideo().execute({"prompt": "test"}) + assert result.success is False + assert "VOLC_ACCESSKEY" in result.error + assert "VOLC_SECRETKEY" in result.error + + def test_safe_error_redacts_keys(self, monkeypatch): + monkeypatch.setenv("VOLC_ACCESSKEY", "my-ak-secret") + monkeypatch.setenv("VOLC_SECRETKEY", "my-sk-secret") + redacted = JimengVideo._safe_error( + Exception("failed with ak=my-ak-secret sk=my-sk-secret") + ) + assert "my-ak-secret" not in redacted + assert "my-sk-secret" not in redacted + assert "[redacted]" in redacted + + def test_safe_error_no_empty_string_bug(self, monkeypatch): + """Regression: when no keys are set, _safe_error must not mangle.""" + monkeypatch.delenv("VOLC_ACCESSKEY", raising=False) + monkeypatch.delenv("VOLC_SECRETKEY", raising=False) + msg = JimengVideo._safe_error(Exception("abc")) + assert msg == "abc" + + def test_sign_returns_authorization_header(self): + headers = JimengVideo._sign( + "POST", "/", + {"Action": "CVSync2AsyncSubmitTask", "Version": "2022-08-31"}, + {}, b'{"prompt":"test"}', + "fake-ak", "fake-sk", + ) + assert "Authorization" in headers + assert "HMAC-SHA256" in headers["Authorization"] + assert "fake-ak" in headers["Authorization"] + assert "Host" in headers + assert "X-Date" in headers + assert "X-Content-Sha256" in headers + + def test_sign_includes_content_type(self): + headers = JimengVideo._sign("POST", "/", {}, {}, b"{}", "ak", "sk") + assert headers["Content-Type"] == "application/json" + + def test_json_or_raise_returns_dict(self): + class FakeResp: + status_code = 200 + def json(self): + return {"code": 10000, "data": {"task_id": "123"}} + assert JimengVideo._json_or_raise(FakeResp()) == {"code": 10000, "data": {"task_id": "123"}} + + def test_json_or_raise_raises_on_non_json(self): + class FakeResp: + status_code = 500 + def json(self): + raise ValueError("not JSON") + with pytest.raises(RuntimeError, match="Non-JSON"): + JimengVideo._json_or_raise(FakeResp()) + + def test_check_code_passes_on_success(self): + JimengVideo._check_code(200, {"code": 10000, "message": "Success"}) + + def test_check_code_raises_on_api_error(self): + with pytest.raises(RuntimeError, match="code=10008"): + JimengVideo._check_code(200, {"code": 10008, "message": "Insufficient balance"}) + + def test_check_code_raises_on_http_error(self): + with pytest.raises(RuntimeError, match="HTTP 401"): + JimengVideo._check_code(401, {"code": 10004, "message": "Auth failed"}) + + def test_check_code_defaults_to_success_when_code_missing(self): + """If code field is absent on HTTP 2xx, default to 10000 (success).""" + JimengVideo._check_code(200, {"data": {"task_id": "123"}}) + + +# ------------------------------------------------------------------ +# Registry discovery +# ------------------------------------------------------------------ + +class TestRegistryDiscovery: + + def test_discoverable(self): + from tools.tool_registry import ToolRegistry + registry = ToolRegistry() + registry.discover() + names = {t.name for t in registry._tools.values()} + assert "jimeng_video" in names + + def test_distinct_from_other_minimax_tools(self): + from tools.tool_registry import ToolRegistry + registry = ToolRegistry() + registry.discover() + jimeng = [t for t in registry._tools.values() if t.name == "jimeng_video"] + assert len(jimeng) == 1 + assert jimeng[0].provider == "volcengine" diff --git a/tools/video/jimeng_video.py b/tools/video/jimeng_video.py new file mode 100644 index 00000000..60d5ea91 --- /dev/null +++ b/tools/video/jimeng_video.py @@ -0,0 +1,394 @@ +"""Volcengine Jimeng (即梦 AI) video generation via the official API. + +Calls the Volcengine visual API directly (visual.volcengineapi.com) using +HMAC-SHA256 V4 request signing with AK/SK credentials. Supports text-to-video +and image-to-video via the Hailuo/Jimeng 3.0 Pro model. + +API flow: POST CVSync2AsyncSubmitTask -> poll CVSync2AsyncGetResult -> +download video_url. + +Authentication uses Volcengine IAM V4 signing (not Bearer token), which +requires an Access Key ID (AK) and Secret Access Key (SK) pair from +console.volcengine.com/iam/keymanage. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import time +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +_HOST = "visual.volcengineapi.com" +_REGION = "cn-north-1" +_SERVICE = "cv" +_ALGORITHM = "HMAC-SHA256" +_API_VERSION = "2022-08-31" +_REQ_KEY_VIDEO = "jimeng_ti2v_v30_pro" + + +class JimengVideo(BaseTool): + name = "jimeng_video" + version = "0.1.0" + tier = ToolTier.GENERATE + capability = "video_generation" + provider = "volcengine" + stability = ToolStability.EXPERIMENTAL + execution_mode = ExecutionMode.ASYNC + determinism = Determinism.STOCHASTIC + runtime = ToolRuntime.API + + dependencies = [] + install_instructions = ( + "Set VOLC_ACCESSKEY and VOLC_SECRETKEY to your Volcengine IAM credentials.\n" + " Get them at https://console.volcengine.com/iam/keymanage\n" + " Ensure your account has access to Jimeng AI (即梦) video generation." + ) + agent_skills = ["ai-video-gen"] + + capabilities = ["text_to_video", "image_to_video"] + supports = { + "text_to_video": True, + "image_to_video": True, + "native_audio": False, + "seed": True, + } + best_for = [ + "Jimeng 3.0 Pro text-to-video and image-to-video via Volcengine", + "direct ByteDance API quota usage (not through a gateway)", + "Chinese-language prompt understanding", + ] + not_good_for = ["offline generation", "users without Volcengine AK/SK"] + fallback_tools = ["minimax_tokenplan_video", "kling_video", "veo_video"] + + input_schema = { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": { + "type": "string", + "description": "Video description. Max 2000 chars. Supports Chinese.", + }, + "operation": { + "type": "string", + "enum": ["text_to_video", "image_to_video"], + "default": "text_to_video", + }, + "image_url": { + "type": "string", + "description": ( + "First frame image URL for image-to-video. " + "Must be publicly accessible." + ), + }, + "frames": { + "type": "integer", + "minimum": 1, + "default": 121, + "description": "Total frames. 121=5s, 241=10s at 24fps.", + }, + "aspect_ratio": { + "type": "string", + "enum": ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"], + "default": "16:9", + }, + "seed": { + "type": "integer", + "default": -1, + "description": "Random seed. -1 for random.", + }, + "output_path": {"type": "string"}, + "poll_interval_seconds": { + "type": "number", + "minimum": 2, + "default": 5.0, + }, + "timeout_seconds": { + "type": "integer", + "minimum": 60, + "default": 600, + }, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=500, network_required=True + ) + retry_policy = RetryPolicy( + max_retries=2, + backoff_seconds=2.0, + retryable_errors=["rate_limit", "timeout"], + ) + idempotency_key_fields = [ + "prompt", + "operation", + "image_url", + "frames", + "aspect_ratio", + "seed", + ] + side_effects = [ + "writes video file to output_path", + "calls Volcengine Jimeng API (V4-signed submit + poll + download)", + ] + user_visible_verification = [ + "Watch generated clip for motion coherence and prompt adherence", + ] + + def _ak(self) -> str | None: + val = os.environ.get("VOLC_ACCESSKEY", "") + if val and not val.strip().startswith("#"): + return val.strip() + return None + + def _sk(self) -> str | None: + val = os.environ.get("VOLC_SECRETKEY", "") + if val and not val.strip().startswith("#"): + return val.strip() + return None + + def get_status(self) -> ToolStatus: + if self._ak() and self._sk(): + return ToolStatus.AVAILABLE + return ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + frames = int(inputs.get("frames", 121)) + seconds = frames / 24.0 + return round(0.05 * seconds, 2) + + def estimate_runtime(self, inputs: dict[str, Any]) -> float: + return 120.0 + int(inputs.get("frames", 121)) * 1.0 + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + ak = self._ak() + sk = self._sk() + if not ak or not sk: + return ToolResult( + success=False, + error="VOLC_ACCESSKEY or VOLC_SECRETKEY not set. " + self.install_instructions, + ) + + operation = inputs.get("operation", "text_to_video") + if operation == "image_to_video" and not inputs.get("image_url"): + return ToolResult( + success=False, + error="image_to_video requires image_url (public URL).", + ) + + start = time.time() + try: + result = self._generate(inputs, ak=ak, sk=sk) + except Exception as exc: + return ToolResult( + success=False, + error=f"Jimeng video generation failed: {self._safe_error(exc)}", + ) + + result.duration_seconds = round(time.time() - start, 2) + return result + + def _generate(self, inputs: dict[str, Any], *, ak: str, sk: str) -> ToolResult: + import requests + + from tools.video._shared import probe_output + + payload = self._build_payload(inputs) + task_id = self._submit_task(payload, ak=ak, sk=sk) + video_url = self._poll_task( + task_id, ak=ak, sk=sk, + poll_interval=float(inputs.get("poll_interval_seconds", 5.0)), + timeout_seconds=int(inputs.get("timeout_seconds", 600)), + ) + + download = requests.get(video_url, timeout=120) + download.raise_for_status() + + output_path = Path(inputs.get("output_path", "jimeng_video.mp4")) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(download.content) + + probed = probe_output(output_path) + return ToolResult( + success=True, + data={ + "provider": "volcengine", + "route": "jimeng_direct", + "model": _REQ_KEY_VIDEO, + "prompt": inputs["prompt"], + "operation": inputs.get("operation", "text_to_video"), + "frames": payload.get("frames", 121), + "aspect_ratio": payload.get("aspect_ratio", "16:9"), + "seed": payload.get("seed", -1), + "task_id": task_id, + "video_url": video_url, + "output": str(output_path), + "format": "mp4", + **probed, + }, + artifacts=[str(output_path)], + cost_usd=self.estimate_cost(inputs), + model=_REQ_KEY_VIDEO, + ) + + @staticmethod + def _build_payload(inputs: dict[str, Any]) -> dict[str, Any]: + operation = inputs.get("operation", "text_to_video") + payload: dict[str, Any] = { + "req_key": _REQ_KEY_VIDEO, + "prompt": inputs["prompt"], + "frames": int(inputs.get("frames", 121)), + "aspect_ratio": inputs.get("aspect_ratio", "16:9"), + "seed": int(inputs.get("seed", -1)), + } + if operation == "image_to_video" and inputs.get("image_url"): + payload["image_urls"] = [inputs["image_url"]] + return payload + + def _submit_task(self, payload: dict[str, Any], *, ak: str, sk: str) -> str: + import requests + + query = {"Action": "CVSync2AsyncSubmitTask", "Version": _API_VERSION} + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers = self._sign("POST", "/", query, {}, body, ak, sk) + url = f"https://{_HOST}/?{urllib.parse.urlencode(sorted(query.items()))}" + resp = requests.post(url, data=body, headers=headers, timeout=30) + data = self._json_or_raise(resp) + self._check_code(resp.status_code, data) + task_id = data.get("data", {}).get("task_id") + if not task_id: + raise RuntimeError(f"Jimeng submit returned no task_id: {data}") + return task_id + + def _poll_task( + self, task_id: str, *, ak: str, sk: str, + poll_interval: float, timeout_seconds: int, + ) -> str: + import requests + + query = {"Action": "CVSync2AsyncGetResult", "Version": _API_VERSION} + body = json.dumps({ + "req_key": _REQ_KEY_VIDEO, + "task_id": task_id, + "req_json": json.dumps({"return_url": True}), + }, ensure_ascii=False).encode("utf-8") + + deadline = time.time() + timeout_seconds + while time.time() < deadline: + time.sleep(poll_interval) + headers = self._sign("POST", "/", query, {}, body, ak, sk) + url = f"https://{_HOST}/?{urllib.parse.urlencode(sorted(query.items()))}" + resp = requests.post(url, data=body, headers=headers, timeout=30) + data = self._json_or_raise(resp) + self._check_code(resp.status_code, data) + status = (data.get("data") or {}).get("status", "") + if status == "done": + video_url = (data.get("data") or {}).get("video_url") + if not video_url: + raise RuntimeError(f"Jimeng task done but no video_url: {data}") + return video_url + if status in ("not_found", "expired"): + raise RuntimeError(f"Jimeng task invalid: status={status}") + raise TimeoutError(f"Jimeng task {task_id} did not finish within {timeout_seconds}s") + + @staticmethod + def _sign( + method: str, path: str, query_params: dict, + headers: dict, body: bytes, ak: str, sk: str, + ) -> dict: + now = datetime.now(timezone.utc) + x_date = now.strftime("%Y%m%dT%H%M%SZ") + short_date = x_date[:8] + + body_hash = hashlib.sha256(body).hexdigest() + headers = dict(headers) + headers["Host"] = _HOST + headers["X-Date"] = x_date + headers["X-Content-Sha256"] = body_hash + headers["Content-Type"] = "application/json" + + lower_headers = {k.lower(): v.strip() for k, v in headers.items()} + signed_names = sorted(lower_headers) + canonical_headers = "".join( + f"{k}:{lower_headers[k]}\n" + for k in signed_names + ) + signed_str = ";".join(signed_names) + + canonical_query = "&".join( + f"{urllib.parse.quote(str(k), safe='')}={urllib.parse.quote(str(v), safe='')}" + for k, v in sorted(query_params.items()) + ) + + canonical_request = "\n".join([ + method.upper(), path, canonical_query, + canonical_headers, signed_str, body_hash, + ]) + + credential_scope = f"{short_date}/{_REGION}/{_SERVICE}/request" + string_to_sign = "\n".join([ + _ALGORITHM, x_date, credential_scope, + hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), + ]) + + k_date = hmac.new(sk.encode("utf-8"), short_date.encode("utf-8"), hashlib.sha256).digest() + k_region = hmac.new(k_date, _REGION.encode("utf-8"), hashlib.sha256).digest() + k_service = hmac.new(k_region, _SERVICE.encode("utf-8"), hashlib.sha256).digest() + k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest() + + signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + headers["Authorization"] = ( + f"{_ALGORITHM} Credential={ak}/{credential_scope}, " + f"SignedHeaders={signed_str}, Signature={signature}" + ) + return headers + + @staticmethod + def _safe_error(exc: Exception) -> str: + msg = str(exc) + for var in ("VOLC_SECRETKEY", "VOLC_ACCESSKEY"): + val = os.environ.get(var, "") + if val: + msg = msg.replace(val, "[redacted]") + return msg + + @staticmethod + def _json_or_raise(response: Any) -> dict[str, Any]: + try: + return response.json() + except ValueError as exc: + raise RuntimeError( + f"Non-JSON response from Jimeng API: HTTP {response.status_code}" + ) from exc + + @staticmethod + def _check_code(http_status: int, payload: dict[str, Any]) -> None: + if http_status < 400: + code = payload.get("code", 10000) + if code == 10000: + return + msg = payload.get("message", "unknown error") + raise RuntimeError(f"Jimeng API error: code={code}, msg={msg}") + code = payload.get("code", "unknown") + msg = payload.get("message", "unknown error") + raise RuntimeError(f"Jimeng API error: HTTP {http_status}, code={code}, msg={msg}") From 2ad71dd591d0691b04f3e54f5ce73ae0de384477 Mon Sep 17 00:00:00 2001 From: Yiyabo Date: Fri, 10 Jul 2026 18:54:25 +0800 Subject: [PATCH 02/10] fix: use minimax_video (not minimax_tokenplan_video) in fallback_tools minimax_tokenplan_video does not exist in main branch (added in PR #297, not yet merged). Use minimax_video which is the existing tool. Fixes Copilot review comments on fallback_tools reference and contract test assertion. --- tests/contracts/test_jimeng_video.py | 2 +- tools/video/jimeng_video.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contracts/test_jimeng_video.py b/tests/contracts/test_jimeng_video.py index 9cd6d124..a5fd0f74 100644 --- a/tests/contracts/test_jimeng_video.py +++ b/tests/contracts/test_jimeng_video.py @@ -61,7 +61,7 @@ class TestContract: def test_has_fallbacks(self): tool = JimengVideo() - assert "minimax_tokenplan_video" in tool.fallback_tools + assert "minimax_video" in tool.fallback_tools assert "kling_video" in tool.fallback_tools def test_has_install_instructions(self): diff --git a/tools/video/jimeng_video.py b/tools/video/jimeng_video.py index 60d5ea91..3208a05f 100644 --- a/tools/video/jimeng_video.py +++ b/tools/video/jimeng_video.py @@ -78,7 +78,7 @@ class JimengVideo(BaseTool): "Chinese-language prompt understanding", ] not_good_for = ["offline generation", "users without Volcengine AK/SK"] - fallback_tools = ["minimax_tokenplan_video", "kling_video", "veo_video"] + fallback_tools = ["minimax_video", "kling_video", "veo_video"] input_schema = { "type": "object", From 8414c485adc151639bf4a6de658fc85fe218981d Mon Sep 17 00:00:00 2001 From: Yiyabo Date: Mon, 13 Jul 2026 12:55:27 +0800 Subject: [PATCH 03/10] fix: restore requests module after test_lazy_imports_requests Use monkeypatch.delitem instead of manual del sys.modules['requests'] so pytest automatically restores the module after the test. This prevents 12 downstream Google auth/music/Veo tests from failing with AttributeError: module 'requests' has no attribute 'exceptions'. Fixes calesthio's review feedback on PR #341. --- tests/contracts/test_jimeng_video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contracts/test_jimeng_video.py b/tests/contracts/test_jimeng_video.py index a5fd0f74..5cbf5dde 100644 --- a/tests/contracts/test_jimeng_video.py +++ b/tests/contracts/test_jimeng_video.py @@ -107,12 +107,12 @@ class TestContract: def test_has_user_visible_verification(self): assert len(JimengVideo().user_visible_verification) > 0 - def test_lazy_imports_requests(self): + def test_lazy_imports_requests(self, monkeypatch): import importlib import sys mod_name = "tools.video.jimeng_video" if "requests" in sys.modules: - del sys.modules["requests"] + monkeypatch.delitem(sys.modules, "requests") importlib.reload(sys.modules[mod_name]) def test_estimate_cost_returns_float(self): From 53773cd5fc25e539a8d136276449a44338bd7aec Mon Sep 17 00:00:00 2001 From: Yiyabo Date: Sun, 19 Jul 2026 21:47:06 +0800 Subject: [PATCH 04/10] fix: tighten input_schema per Volcengine Jimeng 3.0 Pro contract - frames: enum [121, 241] (was minimum 1) - prompt: maxLength 800 (was 2000) - seed: minimum -1 (was unbounded) - Add 9 schema validation rejection tests - Add authoritative API reference link to PROVIDERS.md - Document CVSync2Async* route choice and schema constraints Fixes calesthio's second review feedback on PR #341. --- docs/PROVIDERS.md | 9 +++++ tests/contracts/test_jimeng_video.py | 60 ++++++++++++++++++++++++++++ tools/video/jimeng_video.py | 6 ++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0dc38b18..02f3f7e3 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -123,8 +123,17 @@ Authentication uses Volcengine IAM V4 signing (HMAC-SHA256), not a Bearer token. API flow: `POST ?Action=CVSync2AsyncSubmitTask` → poll `POST ?Action=CVSync2AsyncGetResult` → download `video_url`. +The implementation uses the compatible generic `CVSync2Async*` route (API version `2022-08-31`) rather than the model-specific `2024-06-06` actions presented in the public API explorer. This is intentional — the generic route supports the same Jimeng 3.0 Pro model via `req_key` while remaining stable across model updates. + The `req_key` for video is `jimeng_ti2v_v30_pro`. Success code is `10000`. Task statuses: `in_queue`, `generating`, `done`, `not_found`, `expired`. +**Authoritative API reference:** [Jimeng TI2V V30 Pro SubmitTask](https://api.volcengine.com/api-docs/view?action=JimengTI2VV30PROSubmitTask&serviceCode=cv&version=2024-06-06) + +**Schema constraints** (enforced by `input_schema` to prevent paid-call failures): +- `prompt`: max 800 characters +- `frames`: must be exactly `121` (5s) or `241` (10s) at 24fps +- `seed`: `-1` for random, or any non-negative integer + #### Pricing | Model | Price | diff --git a/tests/contracts/test_jimeng_video.py b/tests/contracts/test_jimeng_video.py index 5cbf5dde..5d060dba 100644 --- a/tests/contracts/test_jimeng_video.py +++ b/tests/contracts/test_jimeng_video.py @@ -318,3 +318,63 @@ class TestRegistryDiscovery: jimeng = [t for t in registry._tools.values() if t.name == "jimeng_video"] assert len(jimeng) == 1 assert jimeng[0].provider == "volcengine" + + +# ------------------------------------------------------------------ +# Schema validation — reject invalid inputs before paid API call +# ------------------------------------------------------------------ + +class TestSchemaValidation: + + def test_frames_accepts_121(self): + schema = JimengVideo().input_schema + valid = schema["properties"]["frames"] + assert valid["enum"] == [121, 241] + + def test_frames_rejects_non_enum(self): + import jsonschema + schema = JimengVideo().input_schema + for invalid in [1, 100, 200, 500, 0, -1]: + instance = {"prompt": "test", "frames": invalid} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance, schema) + + def test_prompt_max_length_800(self): + schema = JimengVideo().input_schema + assert schema["properties"]["prompt"]["maxLength"] == 800 + + def test_prompt_rejects_over_800_chars(self): + import jsonschema + schema = JimengVideo().input_schema + instance = {"prompt": "x" * 801} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance, schema) + + def test_prompt_accepts_800_chars(self): + import jsonschema + schema = JimengVideo().input_schema + instance = {"prompt": "x" * 800} + jsonschema.validate(instance, schema) + + def test_seed_minimum_is_negative_one(self): + schema = JimengVideo().input_schema + assert schema["properties"]["seed"]["minimum"] == -1 + + def test_seed_rejects_below_negative_one(self): + import jsonschema + schema = JimengVideo().input_schema + for invalid in [-2, -10, -100]: + instance = {"prompt": "test", "seed": invalid} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance, schema) + + def test_seed_accepts_negative_one(self): + import jsonschema + schema = JimengVideo().input_schema + jsonschema.validate({"prompt": "test", "seed": -1}, schema) + + def test_seed_accepts_zero_and_positive(self): + import jsonschema + schema = JimengVideo().input_schema + for valid in [0, 1, 42, 999999]: + jsonschema.validate({"prompt": "test", "seed": valid}, schema) diff --git a/tools/video/jimeng_video.py b/tools/video/jimeng_video.py index 3208a05f..9410b864 100644 --- a/tools/video/jimeng_video.py +++ b/tools/video/jimeng_video.py @@ -86,7 +86,8 @@ class JimengVideo(BaseTool): "properties": { "prompt": { "type": "string", - "description": "Video description. Max 2000 chars. Supports Chinese.", + "maxLength": 800, + "description": "Video description. Max 800 chars. Supports Chinese.", }, "operation": { "type": "string", @@ -102,7 +103,7 @@ class JimengVideo(BaseTool): }, "frames": { "type": "integer", - "minimum": 1, + "enum": [121, 241], "default": 121, "description": "Total frames. 121=5s, 241=10s at 24fps.", }, @@ -113,6 +114,7 @@ class JimengVideo(BaseTool): }, "seed": { "type": "integer", + "minimum": -1, "default": -1, "description": "Random seed. -1 for random.", }, From e0bfcb44a64b5b71ad69a375be86d08cd6ab2881 Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 22 Jul 2026 07:16:53 -0700 Subject: [PATCH 05/10] Update README capability counts --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3355e890..5b4f1565 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ This repo is built for agentic operation. If you're an OpenClaw-style agent, her # Image + video gateway: FAL_KEY=your-key # FLUX images + Google Veo, Kling, MiniMax video + Recraft images +ATLASCLOUD_API_KEY=your-key # Atlas Cloud — Seedream/Nano Banana/GPT Image + Kling/Seedance/Hailuo video # Kling official direct API: KLING_API_KEY=your-key # Official Kling video, image, TTS, avatar, lip sync @@ -379,8 +380,8 @@ Most "free AI video" stacks quietly mean "animate still images." OpenMontage can Edit your own talking-head footage. Generate a fully animated explainer from scratch. Cut a 2-hour podcast into a dozen social clips. Translate and dub your content into 10 languages. Build a cinematic brand teaser from stock footage and AI-generated scenes. **If a production team can make it, OpenMontage can orchestrate it.** - **12 production pipelines** — explainers, talking heads, screen demos, cinematic trailers, animations, podcasts, localization, documentary montages, and more -- **52 production tools** — spanning video generation, image creation, text-to-speech, music, audio mixing, subtitles, enhancement, and analysis -- **400+ agent skills** — production skills, pipeline directors, creative techniques, quality checklists, and deep technology knowledge packs that teach the agent how to use every tool like an expert +- **100+ production tools** — spanning video generation, image creation, text-to-speech, music, audio mixing, subtitles, enhancement, and analysis +- **700+ agent skill and production-knowledge files** — pipeline directors, creative techniques, quality checklists, and deep technology knowledge packs that teach the agent how to use every tool like an expert - **Reference-driven creation** — paste a video you like and the agent turns it into a grounded, differentiated production plan instead of forcing you to invent the perfect prompt from scratch - **Real-footage documentary creation without paid video models** — build actual edited videos from free/open motion footage and archival sources, not just Ken Burns over images - **Live web research built in** — before writing a single word of script, the agent runs 15-25+ web searches across YouTube, Reddit, news sites, and academic sources to ground your video in real, current data @@ -437,7 +438,7 @@ Final video output -- only if self-review passes ``` OpenMontage/ -├── tools/ # 48 Python tools (the agent's hands) +├── tools/ # 100+ Python tools (the agent's hands) │ ├── video/ # 13 video gen tools + compose, stitch, trim │ ├── audio/ # 4 TTS providers + Suno/ElevenLabs music, mixing, enhancement │ ├── graphics/ # 9 image/graphics generation tools + diagrams, code snippets, math From d4426f6e94cb3dff8106081d9f886d789ab098a0 Mon Sep 17 00:00:00 2001 From: Yiyabo Date: Thu, 23 Jul 2026 01:33:36 +0800 Subject: [PATCH 06/10] fix: declare env dependencies and map selector duration to frames - Add env:VOLC_ACCESSKEY and env:VOLC_SECRETKEY to dependencies - Map selector 'duration' (seconds) to Jimeng 'frames' (121/241) - Add 4 selector duration mapping regression tests - 59 tests pass Fixes calesthio's third review feedback on PR #341. --- tests/contracts/test_jimeng_video.py | 23 +++++++++++++++++++++++ tools/video/jimeng_video.py | 13 +++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/contracts/test_jimeng_video.py b/tests/contracts/test_jimeng_video.py index 5d060dba..56236c18 100644 --- a/tests/contracts/test_jimeng_video.py +++ b/tests/contracts/test_jimeng_video.py @@ -378,3 +378,26 @@ class TestSchemaValidation: schema = JimengVideo().input_schema for valid in [0, 1, 42, 999999]: jsonschema.validate({"prompt": "test", "seed": valid}, schema) + + +# ------------------------------------------------------------------ +# Selector duration → frames mapping +# ------------------------------------------------------------------ + +class TestSelectorDurationMapping: + + def test_duration_5_maps_to_121_frames(self): + payload = JimengVideo._build_payload({"prompt": "x", "duration": 5}) + assert payload["frames"] == 121 + + def test_duration_10_maps_to_241_frames(self): + payload = JimengVideo._build_payload({"prompt": "x", "duration": 10}) + assert payload["frames"] == 241 + + def test_duration_defaults_to_5_when_absent(self): + payload = JimengVideo._build_payload({"prompt": "x"}) + assert payload["frames"] == 121 + + def test_frames_takes_priority_over_duration(self): + payload = JimengVideo._build_payload({"prompt": "x", "frames": 241, "duration": 5}) + assert payload["frames"] == 241 diff --git a/tools/video/jimeng_video.py b/tools/video/jimeng_video.py index 9410b864..9000054e 100644 --- a/tools/video/jimeng_video.py +++ b/tools/video/jimeng_video.py @@ -57,7 +57,7 @@ class JimengVideo(BaseTool): determinism = Determinism.STOCHASTIC runtime = ToolRuntime.API - dependencies = [] + dependencies = ["env:VOLC_ACCESSKEY", "env:VOLC_SECRETKEY"] install_instructions = ( "Set VOLC_ACCESSKEY and VOLC_SECRETKEY to your Volcengine IAM credentials.\n" " Get them at https://console.volcengine.com/iam/keymanage\n" @@ -252,13 +252,22 @@ class JimengVideo(BaseTool): model=_REQ_KEY_VIDEO, ) + @staticmethod + def _duration_to_frames(duration: int) -> int: + if duration >= 10: + return 241 + return 121 + @staticmethod def _build_payload(inputs: dict[str, Any]) -> dict[str, Any]: operation = inputs.get("operation", "text_to_video") + frames = inputs.get("frames") + if frames is None: + frames = JimengVideo._duration_to_frames(int(inputs.get("duration", 5))) payload: dict[str, Any] = { "req_key": _REQ_KEY_VIDEO, "prompt": inputs["prompt"], - "frames": int(inputs.get("frames", 121)), + "frames": int(frames), "aspect_ratio": inputs.get("aspect_ratio", "16:9"), "seed": int(inputs.get("seed", -1)), } From c36e41223e819441748817105635ac4036d41b10 Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 24 Jul 2026 12:21:43 -0700 Subject: [PATCH 07/10] Make Monty the Clapper the OpenMontage mascot Replace the play-button logo in both READMEs with animated SVG versions of Monty, served via + prefers-color-scheme so the mark reads on GitHub's light and dark themes. Motion uses SMIL animateTransform rather than CSS keyframes so it survives the rendering context without depending on transform-box: view-box. Rebuild the 1280x640 social preview around Monty in the ink/cream/terracotta palette, and refresh its stat row to the current counts (12 pipelines, 100+ tools, 700+ agent skills). The card's source HTML ships alongside it so future count updates are an edit and a re-screenshot. --- README.md | 7 ++- README_zh-CN.md | 7 ++- assets/monty-dark.svg | 47 +++++++++++++++ assets/monty-light.svg | 47 +++++++++++++++ assets/social_preview.png | Bin 113953 -> 177191 bytes assets/social_preview.source.html | 94 ++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 assets/monty-dark.svg create mode 100644 assets/monty-light.svg create mode 100644 assets/social_preview.source.html diff --git a/README.md b/README.md index 5b4f1565..6dd89ecd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@

- OpenMontage + + + Monty the Clapper — the official mascot of OpenMontage +

+

Monty the Clapper — the official mascot of OpenMontage

+

OpenMontage

The first open-source, agentic video production system.

diff --git a/README_zh-CN.md b/README_zh-CN.md index ca663047..b8e3b7f2 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -1,7 +1,12 @@

- OpenMontage + + + Monty the Clapper — OpenMontage 官方吉祥物 +

+

Monty the Clapper — OpenMontage 官方吉祥物

+

OpenMontage

首个开源的,代理化(agentic)的视频制作系统

diff --git a/assets/monty-dark.svg b/assets/monty-dark.svg new file mode 100644 index 00000000..dc13e5d4 --- /dev/null +++ b/assets/monty-dark.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/monty-light.svg b/assets/monty-light.svg new file mode 100644 index 00000000..498da5fa --- /dev/null +++ b/assets/monty-light.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/social_preview.png b/assets/social_preview.png index e60384094318c02069ffebe8e2e433f3887008ce..86b4aeb76139bd31b42aaf8cfcdeb01687bb7fdb 100644 GIT binary patch literal 177191 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

nZlFC*|Aot|i6s0^@9+2oc^Wv{qv-RRm69x>A< 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
    #7&?4nMmDa+jl@TD!&D}}T$;_rfW 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 a7{aACUS!tnftm-uaOqw#(#+Gi_3*#m?u{`3 literal 113953 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 X&RyjM?^ZZ5G)7BP?-B07(^vloKdv0j diff --git a/assets/social_preview.source.html b/assets/social_preview.source.html new file mode 100644 index 00000000..02c90945 --- /dev/null +++ b/assets/social_preview.source.html @@ -0,0 +1,94 @@ + + + + + + + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    OpenMontage

    +
    +

    The first open-source, agentic
    video production system.

    +
    12 pipelines·100+ tools·700+ agent skills
    +
    +
    + +
    openmontage.video
    + + From 9482eddeff1f11cc352ca1d2d49ce26981ee3023 Mon Sep 17 00:00:00 2001 From: calesthio Date: Mon, 3 Aug 2026 02:14:01 -0700 Subject: [PATCH 08/10] fix: recover bounded defects from PR backlog --- .env.example | 107 +++++--- AGENT_GUIDE.md | 7 +- backlot/server.py | 27 +- backlot/state.py | 2 + lib/checkpoint.py | 139 ++++++++-- lib/clip_embedder.py | 18 +- lib/pipeline_loader.py | 4 +- remotion-composer/package-lock.json | 21 +- remotion-composer/package.json | 1 + remotion-composer/src/CinematicRenderer.tsx | 26 +- remotion-composer/src/CollageBurst.tsx | 23 +- remotion-composer/src/Explainer.tsx | 89 ++++--- remotion-composer/src/LyricOverlay.tsx | 23 +- remotion-composer/src/TitledVideo.tsx | 29 +- remotion-composer/src/cinematic/types.ts | 1 + .../src/components/AnimeScene.tsx | 18 +- .../src/components/CaptionOverlay.tsx | 4 +- remotion-composer/src/components/EndTag.tsx | 4 +- .../src/components/HeroTitle.tsx | 4 +- .../src/components/ProductReveal.tsx | 4 +- .../src/components/ProviderChip.tsx | 2 +- .../src/components/ScreenshotScene.tsx | 21 +- remotion-composer/src/lib/resolveAsset.ts | 28 ++ schemas/artifacts/__init__.py | 2 +- schemas/artifacts/edit_decisions.schema.json | 4 + .../pipelines/pipeline_manifest.schema.json | 2 +- scripts/backlot_screenshot_stage.py | 9 + skills/INDEX.md | 4 +- skills/pipelines/cinematic/idea-director.md | 15 +- .../pipelines/cinematic/proposal-director.md | 8 +- .../documentary-montage/idea-director.md | 7 +- skills/pipelines/explainer/idea-director.md | 4 +- .../pipelines/explainer/proposal-director.md | 14 +- skills/pipelines/explainer/scene-director.md | 22 +- skills/pipelines/explainer/script-director.md | 4 +- styles/playbook_loader.py | 4 +- tests/backlot/test_gate_scenarios.py | 31 ++- tests/backlot/test_ui_bug_bash.py | 32 ++- .../test_agent_instruction_integrity.py | 31 +++ tests/contracts/test_backlot_contract.py | 26 ++ tests/contracts/test_env_example.py | 19 ++ tests/contracts/test_phase2_contracts.py | 8 +- tests/contracts/test_phase3_contracts.py | 21 +- .../test_pipeline_manifest_categories.py | 9 + ...test_remotion_video_transition_contract.py | 28 ++ tests/contracts/test_utf8_file_io.py | 30 +++ tests/lib/test_checkpoint_prerequisites.py | 152 +++++++++++ tests/lib/test_clip_embedder_compat.py | 26 ++ .../tools/test_audio_mixer_target_duration.py | 94 +++++++ tests/tools/test_bg_remove_api.py | 28 ++ .../tools/test_cinematic_remotion_adapter.py | 177 +++++++++++++ .../test_corpus_builder_total_failure.py | 59 +++++ tests/tools/test_google_vertex_backends.py | 97 +++++++ tests/tools/test_hyperframes_compose.py | 30 +++ tests/tools/test_mps_device.py | 8 +- tests/tools/test_remotion_audio_mux.py | 59 +++++ .../test_transcriber_device_selection.py | 83 ++++++ tools/analysis/transcriber.py | 110 +++++--- tools/audio/audio_mixer.py | 48 +++- tools/audio/google_music.py | 3 +- tools/enhancement/bg_remove.py | 2 +- tools/enhancement/upscale.py | 5 + tools/google_credentials.py | 19 +- tools/graphics/google_imagen.py | 3 +- tools/video/corpus_builder.py | 32 +++ tools/video/hyperframes_compose.py | 48 ++++ tools/video/veo_video.py | 21 +- tools/video/video_compose.py | 249 ++++++++++++++++-- 68 files changed, 1913 insertions(+), 376 deletions(-) create mode 100644 remotion-composer/src/lib/resolveAsset.ts create mode 100644 tests/contracts/test_agent_instruction_integrity.py create mode 100644 tests/contracts/test_env_example.py create mode 100644 tests/contracts/test_pipeline_manifest_categories.py create mode 100644 tests/contracts/test_remotion_video_transition_contract.py create mode 100644 tests/contracts/test_utf8_file_io.py create mode 100644 tests/lib/test_checkpoint_prerequisites.py create mode 100644 tests/lib/test_clip_embedder_compat.py create mode 100644 tests/tools/test_audio_mixer_target_duration.py create mode 100644 tests/tools/test_bg_remove_api.py create mode 100644 tests/tools/test_cinematic_remotion_adapter.py create mode 100644 tests/tools/test_corpus_builder_total_failure.py create mode 100644 tests/tools/test_google_vertex_backends.py create mode 100644 tests/tools/test_remotion_audio_mux.py create mode 100644 tests/tools/test_transcriber_device_selection.py diff --git a/.env.example b/.env.example index d60f8987..51d973e4 100644 --- a/.env.example +++ b/.env.example @@ -2,72 +2,103 @@ # Copy this to .env and fill in your keys # --- Image + video gateway --- -FAL_KEY= # FLUX images, Google Veo video, Kling video, MiniMax video, Recraft images - # Get one at https://fal.ai/dashboard/keys -FAL_AI_API_KEY= # Alias for FAL_KEY (some SDKs/docs use this name); either one is read. +# FLUX images, Google Veo video, Kling video, MiniMax video, Recraft images. +# Get one at https://fal.ai/dashboard/keys +FAL_KEY= +# Alias for FAL_KEY (some SDKs/docs use this name); either one is read. +FAL_AI_API_KEY= # --- Replicate --- -REPLICATE_API_TOKEN= # Replicate-hosted video gen (seedance_replicate). Needed to make the - # Replicate-backed Seedance path selectable alongside the fal.ai one. - # Get one at https://replicate.com/account/api-tokens +# Replicate-hosted video gen (seedance_replicate). Needed to make the +# Replicate-backed Seedance path selectable alongside the fal.ai one. +# Get one at https://replicate.com/account/api-tokens +REPLICATE_API_TOKEN= # --- Higgsfield --- -HIGGSFIELD_API_KEY= # Higgsfield Cloud key (higgsfield_video). Pair with the secret below, -HIGGSFIELD_API_SECRET= # or use the combined HIGGSFIELD_KEY=":" form instead. +# Higgsfield Cloud key (higgsfield_video). Pair with the secret below, +# or use the combined HIGGSFIELD_KEY=":" form instead. +HIGGSFIELD_API_KEY= +HIGGSFIELD_API_SECRET= # HIGGSFIELD_KEY= # Combined key:secret — set this INSTEAD of the _KEY/_SECRET pair if you prefer. # --- Kling official direct API --- -KLING_API_KEY= # Official Kling API key; enables video, image, TTS, avatar, lip sync -KLING_API_BASE_URL= # Optional endpoint override; leave blank for default https://api-singapore.klingai.com - # Mainland China accounts can use https://api-beijing.klingai.com +# Official Kling API key; enables video, image, TTS, avatar, lip sync. +KLING_API_KEY= +# Optional endpoint override; leave blank for default https://api-singapore.klingai.com +# Mainland China accounts can use https://api-beijing.klingai.com +KLING_API_BASE_URL= # --- Google (one key unlocks image gen + TTS + video) --- -GOOGLE_API_KEY= # Google Imagen images, Google Cloud TTS (700+ voices, 50+ languages), - # Gemini Omni video (generation + conversational editing, paid tier) - # Get one at https://aistudio.google.com/apikey +# Google Imagen images, Google Cloud TTS (700+ voices, 50+ languages), +# Gemini Omni video (generation + conversational editing, paid tier). +# Get one at https://aistudio.google.com/apikey +GOOGLE_API_KEY= # GEMINI_API_KEY= # Alias for GOOGLE_API_KEY (takes precedence when both are set) # Alternative to the API key: service-account JSON auth. # TTS uses Cloud Text-to-Speech; Imagen routes to Vertex AI. -GOOGLE_APPLICATION_CREDENTIALS= # path to a service-account JSON key file -GOOGLE_CLOUD_PROJECT= # GCP project id (required for Imagen via Vertex AI) -GOOGLE_CLOUD_LOCATION= # Vertex AI region, default us-central1 +# Path to a service-account JSON key file. +GOOGLE_APPLICATION_CREDENTIALS= +# GCP project id (required for Imagen via Vertex AI). +GOOGLE_CLOUD_PROJECT= +# Vertex AI region, default us-central1. +GOOGLE_CLOUD_LOCATION= # --- Voice --- -ELEVENLABS_API_KEY= # TTS narration, music generation, sound effects -OPENAI_API_KEY= # OpenAI TTS fallback and GPT Image 2 image generation -XAI_API_KEY= # Grok image generation/editing and Grok video generation -DOUBAO_SPEECH_API_KEY= # Volcengine Doubao Speech TTS (new console API Key) -DOUBAO_SPEECH_VOICE_TYPE= # Default Doubao speaker/voice type, e.g. zh_female_vv_uranus_bigtts +# TTS narration, music generation, sound effects. +ELEVENLABS_API_KEY= +# OpenAI TTS fallback and GPT Image 2 image generation. +OPENAI_API_KEY= +# Grok image generation/editing and Grok video generation. +XAI_API_KEY= +# Volcengine Doubao Speech TTS (new console API Key). +DOUBAO_SPEECH_API_KEY= +# Default Doubao speaker/voice type, e.g. zh_female_vv_uranus_bigtts. +DOUBAO_SPEECH_VOICE_TYPE= # Piper local voices do not require env vars; install `piper-tts` via pip # --- DashScope (Alibaba Cloud Bailian) --- -DASHSCOPE_API_KEY= # Qwen image gen (qwen-image-2.0-pro), TTS (qwen3-tts-flash), ASR with word timestamps (qwen3-asr-flash-filetrans) - # Get one at https://dashscope.aliyun.com/ +# Qwen image gen (qwen-image-2.0-pro), TTS (qwen3-tts-flash), ASR with word timestamps (qwen3-asr-flash-filetrans). +# Get one at https://dashscope.aliyun.com/ +DASHSCOPE_API_KEY= # --- Music --- -SUNO_API_KEY= # Suno AI music generation (full songs, instrumentals, any genre) +# Suno AI music generation (full songs, instrumentals, any genre). +SUNO_API_KEY= # --- Video Generation --- -HEYGEN_API_KEY= # HeyGen API (VEO, Sora, Runway, Kling, Seedance via single key) -RUNWAY_API_KEY= # Runway Gen-4 (direct API, alternative to fal.ai routing) -VOLC_ACCESSKEY= # Volcengine Jimeng (即梦 AI) video generation via official API (HMAC-SHA256 V4 signing) -VOLC_SECRETKEY= # Secret Access Key paired with VOLC_ACCESSKEY. Get both at https://console.volcengine.com/iam/keymanage -VIDEO_GEN_LOCAL_ENABLED= # Set to "true" for local video gen (needs GPU + diffusers) -VIDEO_GEN_LOCAL_MODEL= # Local model: wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b -MODAL_LTX2_ENDPOINT_URL= # Modal self-hosted LTX-2 endpoint (optional) +# HeyGen API (VEO, Sora, Runway, Kling, Seedance via single key). +HEYGEN_API_KEY= +# Runway Gen-4 (direct API, alternative to fal.ai routing). +RUNWAY_API_KEY= +# Volcengine Jimeng (即梦 AI) video generation via official API (HMAC-SHA256 V4 signing). +VOLC_ACCESSKEY= +# Secret Access Key paired with VOLC_ACCESSKEY. Get both at https://console.volcengine.com/iam/keymanage +VOLC_SECRETKEY= +# Set to "true" for local video gen (needs GPU + diffusers). +VIDEO_GEN_LOCAL_ENABLED= +# Local model: wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b. +VIDEO_GEN_LOCAL_MODEL= +# Modal self-hosted LTX-2 endpoint (optional). +MODAL_LTX2_ENDPOINT_URL= # --- Stock Media --- -PEXELS_API_KEY= # Pexels stock footage/images (free) -PIXABAY_API_KEY= # Pixabay stock footage/images (free) -UNSPLASH_ACCESS_KEY= # Unsplash stock images (free developer key) +# Pexels stock footage/images (free). +PEXELS_API_KEY= +# Pixabay stock footage/images (free). +PIXABAY_API_KEY= +# Unsplash stock images (free developer key). +UNSPLASH_ACCESS_KEY= # --- Analysis --- -HF_TOKEN= # HuggingFace token — enables speaker diarization in transcriber +# HuggingFace token — enables speaker diarization in transcriber. +HF_TOKEN= # Speech-to-text: optional Azure AI Speech (Fast Transcription). When set, the # agent prefers azure_stt for cloud STT; the local faster-whisper transcriber # remains the default offline path. -AZURE_SPEECH_KEY= # Azure AI Speech resource key ('Keys and Endpoint' page) -AZURE_SPEECH_REGION= # Speech resource region, e.g. eastus +# Azure AI Speech resource key ('Keys and Endpoint' page). +AZURE_SPEECH_KEY= +# Speech resource region, e.g. eastus. +AZURE_SPEECH_REGION= # AZURE_SPEECH_ENDPOINT= # Optional: full custom endpoint URL (overrides region) # --- Avatar (local installs) --- diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 2a0b6bd5..fe464643 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -528,9 +528,10 @@ Music is a critical part of any video. **Surface the music situation to the user Check music availability in this order and present the options: -1. **User music library (`music_library/`):** Check if this folder exists and contains tracks. If so, list available tracks with durations and let the user pick one. -2. **Music generation APIs:** Check which music tools are available via the registry (`registry.get_by_capability("music_generation")`). Report their status honestly — include quota status if known. -3. **Royalty-free sources:** Note if the user can provide their own track (e.g., from YouTube Audio Library, Jamendo, or other free sources). Offer the `music_library/` drop path. +1. **User music library:** Check `registry.get_by_capability("music_library")` and inspect `music_library/`. If tracks exist, list durations and let the user pick one. +2. **Royalty-free search:** Check `registry.get_by_capability("music_search")` for configured search/download tools. Report licensing constraints and whether a key is required. +3. **Music generation APIs:** Check `registry.get_by_capability("music_generation")`. Report status, quota, cost, and quality tradeoffs honestly. +4. **Bring your own:** Note that the user can provide a track (for example from YouTube Audio Library or Jamendo) through the `music_library/` drop path. **Always present the user with explicit choices:** - Use a track from their library (which one?) diff --git a/backlot/server.py b/backlot/server.py index 7e40e8fc..1016a256 100644 --- a/backlot/server.py +++ b/backlot/server.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import json import time +from contextlib import asynccontextmanager, suppress from pathlib import Path from typing import Optional @@ -147,18 +148,22 @@ async def _watch_projects() -> None: hub.publish(pid) +@asynccontextmanager +async def _lifespan(app: FastAPI): + """Own and cleanly stop the project watcher with FastAPI's lifespan API.""" + + task = asyncio.create_task(_watch_projects()) + app.state.watch_task = task + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + def create_app() -> FastAPI: - app = FastAPI(title="Backlot", docs_url=None, redoc_url=None) - - @app.on_event("startup") - async def _startup() -> None: - app.state.watch_task = asyncio.create_task(_watch_projects()) - - @app.on_event("shutdown") - async def _shutdown() -> None: - task = getattr(app.state, "watch_task", None) - if task: - task.cancel() + app = FastAPI(title="Backlot", docs_url=None, redoc_url=None, lifespan=_lifespan) # ---- API ---------------------------------------------------------- diff --git a/backlot/state.py b/backlot/state.py index 3cecfe3b..678cb829 100644 --- a/backlot/state.py +++ b/backlot/state.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import re +from functools import lru_cache from pathlib import Path from typing import Any, Optional @@ -59,6 +60,7 @@ def _rel(project_dir: Path, path: Path) -> str: # Pipeline / stages # --------------------------------------------------------------------------- +@lru_cache(maxsize=32) def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]: """Stage order + gate flags from the manifest; graceful fallback.""" if pipeline_type and pipeline_type != "unknown": diff --git a/lib/checkpoint.py b/lib/checkpoint.py index e35e6164..f0cc4b99 100644 --- a/lib/checkpoint.py +++ b/lib/checkpoint.py @@ -95,9 +95,29 @@ class CheckpointValidationError(ValueError): """Raised when a checkpoint or its canonical artifacts are invalid.""" +def _validate_style_playbook(style_playbook: str | None) -> None: + """Fail closed when a checkpoint names a visual identity that cannot load.""" + + if style_playbook is None: + return + try: + from styles.playbook_loader import list_playbooks, load_playbook + + load_playbook(style_playbook) + except Exception as exc: + try: + available = list_playbooks() + except Exception: + available = [] + raise CheckpointValidationError( + f"Unknown or invalid style_playbook {style_playbook!r}. " + f"Available playbooks: {available}. Underlying error: {exc}" + ) from exc + + @lru_cache(maxsize=1) def _load_checkpoint_schema() -> dict[str, Any]: - with open(CHECKPOINT_SCHEMA_PATH) as f: + with open(CHECKPOINT_SCHEMA_PATH, encoding="utf-8") as f: return json.load(f) @@ -192,6 +212,7 @@ def init_project( Idempotent: re-running preserves the original created_at and merges fields. Returns the project directory. """ + _validate_style_playbook(style_playbook) base = pipeline_dir or PROJECTS_DIR project_dir = base / project_id for sub in ( @@ -208,7 +229,7 @@ def init_project( marker: dict[str, Any] = {} if marker_path.exists(): try: - with open(marker_path) as f: + with open(marker_path, encoding="utf-8") as f: marker = json.load(f) except (json.JSONDecodeError, OSError): marker = {} @@ -221,7 +242,7 @@ def init_project( if style_playbook is not None: marker["style_playbook"] = style_playbook - with open(marker_path, "w") as f: + with open(marker_path, "w", encoding="utf-8") as f: json.dump(marker, f, indent=2) return project_dir @@ -260,6 +281,71 @@ def _stage_requires_approval(pipeline_type: Optional[str], stage: str) -> Option return get_stage_human_approval_default(manifest, stage) +def _enforce_stage_prerequisites( + pipeline_dir: Path, + project_id: str, + pipeline_type: str | None, + stage: str, + status: str, +) -> None: + """Require completed, approved predecessors before advancing a stage. + + ``in_progress`` and failure heartbeats remain writable so an operator can + inspect or resume a broken run. Only lifecycle advancement + (``awaiting_human``/``completed``) is gated. + """ + + if status not in {"awaiting_human", "completed"}: + return + if not pipeline_type or pipeline_type == "unknown": + return + + stages = get_pipeline_stages(pipeline_type) + if stage not in stages: + return + + incomplete: list[str] = [] + unapproved: list[str] = [] + for predecessor in stages[: stages.index(stage)]: + path = _checkpoint_path(pipeline_dir, project_id, predecessor) + if not path.exists(): + incomplete.append(predecessor) + continue + try: + with open(path, encoding="utf-8") as handle: + checkpoint = json.load(handle) + validate_checkpoint(checkpoint) + except (OSError, json.JSONDecodeError, CheckpointValidationError): + incomplete.append(predecessor) + continue + if ( + checkpoint.get("project_id") != project_id + or checkpoint.get("pipeline_type") != pipeline_type + or checkpoint.get("stage") != predecessor + ): + incomplete.append(predecessor) + continue + if checkpoint.get("status") != "completed": + incomplete.append(predecessor) + continue + if _stage_requires_approval(pipeline_type, predecessor) and not checkpoint.get( + "human_approved" + ): + unapproved.append(predecessor) + + if incomplete or unapproved: + details = [] + if incomplete: + details.append(f"incomplete or missing: {incomplete}") + if unapproved: + details.append(f"completed without required approval: {unapproved}") + raise CheckpointValidationError( + f"PREREQUISITE VIOLATION: stage {stage!r} cannot advance; " + + "; ".join(details) + + f". Pipeline order: {stages}." + ) + + def _archive_superseded_checkpoint(path: Path, stage: str) -> None: """Copy an existing checkpoint into history/ before it is overwritten. @@ -275,7 +361,7 @@ def _archive_superseded_checkpoint(path: Path, stage: str) -> None: if not path.exists(): return try: - with open(path) as f: + with open(path, encoding="utf-8") as f: existing = json.load(f) except (json.JSONDecodeError, OSError): existing = {} @@ -314,7 +400,7 @@ def _merge_decision_log( """ path = _decision_log_path(pipeline_dir, project_id) if path.exists(): - with open(path) as f: + with open(path, encoding="utf-8") as f: existing = json.load(f) else: existing = { @@ -329,7 +415,7 @@ def _merge_decision_log( existing["decisions"].append(decision) path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(existing, f, indent=2) @@ -351,19 +437,22 @@ def write_checkpoint( metadata: Optional[dict] = None, ) -> Path: """Write a checkpoint file for a pipeline stage.""" - # Backfill a missing pipeline_type from the project marker so that - # omitting the kwarg doesn't quietly bypass gate enforcement. - if not pipeline_type: - marker = None - marker_path = pipeline_dir / project_id / PROJECT_MARKER_FILENAME - if marker_path.exists(): - try: - with open(marker_path) as f: - marker = json.load(f) - except (json.JSONDecodeError, OSError): - marker = None - if isinstance(marker, dict) and marker.get("pipeline_type"): + # Backfill identity fields from the project marker so omitted kwargs + # cannot bypass either gate enforcement or style validation. + marker = None + marker_path = pipeline_dir / project_id / PROJECT_MARKER_FILENAME + if marker_path.exists() and (not pipeline_type or not style_playbook): + try: + with open(marker_path, encoding="utf-8") as f: + marker = json.load(f) + except (json.JSONDecodeError, OSError): + marker = None + if isinstance(marker, dict): + if not pipeline_type and marker.get("pipeline_type"): pipeline_type = marker["pipeline_type"] + if not style_playbook and marker.get("style_playbook"): + style_playbook = marker["style_playbook"] + _validate_style_playbook(style_playbook) valid_stages = ( set(get_pipeline_stages(pipeline_type)) if pipeline_type @@ -404,6 +493,14 @@ def write_checkpoint( f"re-write with status='completed', human_approved=True." ) + _enforce_stage_prerequisites( + pipeline_dir, + project_id, + pipeline_type, + stage, + status, + ) + checkpoint = { "version": "1.0", "project_id": project_id, @@ -456,7 +553,7 @@ def write_checkpoint( # current checkpoint; then archive the superseded file and swap in the # new one atomically. tmp_path = path.with_suffix(".json.tmp") - with open(tmp_path, "w") as f: + with open(tmp_path, "w", encoding="utf-8") as f: json.dump(checkpoint, f, indent=2) # Preserve run history: a superseded completed/awaiting_human checkpoint # is copied to history/ (stage versioning, gate audit trail, replay). @@ -474,7 +571,7 @@ def read_checkpoint( path = _checkpoint_path(pipeline_dir, project_id, stage) if not path.exists(): return None - with open(path) as f: + with open(path, encoding="utf-8") as f: checkpoint = json.load(f) validate_checkpoint(checkpoint) return checkpoint @@ -496,7 +593,7 @@ def get_latest_checkpoint( if not checkpoints: return None - with open(checkpoints[0]) as f: + with open(checkpoints[0], encoding="utf-8") as f: checkpoint = json.load(f) validate_checkpoint(checkpoint) return checkpoint diff --git a/lib/clip_embedder.py b/lib/clip_embedder.py index b9ee82eb..82f0e1cd 100644 --- a/lib/clip_embedder.py +++ b/lib/clip_embedder.py @@ -61,6 +61,20 @@ def model_info() -> dict: } +def _as_feature_tensor(features): + """Normalize CLIP feature return values across transformers versions. + + Transformers 4 returned the projected tensor directly. Transformers 5 may + wrap that tensor in a model-output object whose ``pooler_output`` contains + the same shared-space embedding. Do not project it again: the vision + projection expects the pre-projection width, while ``pooler_output`` is + already the final CLIP width. + """ + + pooled = getattr(features, "pooler_output", None) + return features if pooled is None else pooled + + def embed_images(image_paths: Sequence[Union[str, Path]]) -> np.ndarray: """Embed a list of image files into a (N, 512) float32 matrix. @@ -82,7 +96,7 @@ def embed_images(image_paths: Sequence[Union[str, Path]]) -> np.ndarray: inputs = _PROCESSOR(images=images, return_tensors="pt").to(_DEVICE) with torch.no_grad(): - features = _MODEL.get_image_features(**inputs) + features = _as_feature_tensor(_MODEL.get_image_features(**inputs)) features = features / features.norm(dim=-1, keepdim=True).clamp_min(1e-8) arr = features.cpu().numpy().astype(np.float32, copy=False) # Close PIL handles to avoid leaking file handles on Windows @@ -116,7 +130,7 @@ def embed_texts(texts: Sequence[str]) -> np.ndarray: max_length=77, ).to(_DEVICE) with torch.no_grad(): - features = _MODEL.get_text_features(**inputs) + features = _as_feature_tensor(_MODEL.get_text_features(**inputs)) features = features / features.norm(dim=-1, keepdim=True).clamp_min(1e-8) return features.cpu().numpy().astype(np.float32, copy=False) diff --git a/lib/pipeline_loader.py b/lib/pipeline_loader.py index 6ced59c7..e2f2003d 100644 --- a/lib/pipeline_loader.py +++ b/lib/pipeline_loader.py @@ -26,7 +26,7 @@ from functools import lru_cache @lru_cache(maxsize=1) def _load_manifest_schema() -> dict: - with open(SCHEMA_PATH) as f: + with open(SCHEMA_PATH, encoding="utf-8") as f: return json.load(f) @@ -61,7 +61,7 @@ def load_pipeline(name: str, defs_dir: Optional[Path] = None) -> dict[str, Any]: if not path.exists(): raise FileNotFoundError(f"Pipeline manifest not found: {path}") - with open(path) as f: + with open(path, encoding="utf-8") as f: manifest = yaml.safe_load(f) schema = _load_manifest_schema() diff --git a/remotion-composer/package-lock.json b/remotion-composer/package-lock.json index 4b527227..da9eabb2 100644 --- a/remotion-composer/package-lock.json +++ b/remotion-composer/package-lock.json @@ -15,6 +15,7 @@ "@remotion/player": "^4.0.484", "@remotion/transitions": "^4.0.484", "d3-geo": "^3.1.1", + "fast-uri": "^3.1.5", "react": "^18.2.0", "react-dom": "^18.2.0", "remotion": "^4.0.484", @@ -1951,9 +1952,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -2253,9 +2254,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -2345,9 +2346,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -2364,7 +2365,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/remotion-composer/package.json b/remotion-composer/package.json index 2e5bfad9..e913bb2c 100644 --- a/remotion-composer/package.json +++ b/remotion-composer/package.json @@ -15,6 +15,7 @@ "@remotion/player": "^4.0.484", "@remotion/transitions": "^4.0.484", "d3-geo": "^3.1.1", + "fast-uri": "^3.1.5", "react": "^18.2.0", "react-dom": "^18.2.0", "remotion": "^4.0.484", diff --git a/remotion-composer/src/CinematicRenderer.tsx b/remotion-composer/src/CinematicRenderer.tsx index f3c27412..926c59c7 100644 --- a/remotion-composer/src/CinematicRenderer.tsx +++ b/remotion-composer/src/CinematicRenderer.tsx @@ -8,31 +8,13 @@ import { Sequence, interpolate, spring, - staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; -function resolveAsset(src: string): string { - if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) { - return src; - } - const clean = src.replace(/^file:\/\/\/?/, ""); - if (clean.startsWith("/") || /^[A-Za-z]:[/\\]/.test(clean)) { - const posix = clean.replace(/\\/g, "/"); - // POSIX absolute paths already have a leading "/" — file:// + posix - // gives exactly three slashes. Windows drive paths (C:/...) need the - // extra slash added explicitly. Do not merge these branches — adding - // "file:///" unconditionally double-slashes POSIX paths (file:////...). - if (posix.startsWith("/")) { - return `file://${posix}`; - } - return `file:///${posix}`; - } - return staticFile(clean); -} import { CinematicRendererProps, CinematicTone, CinematicVideoScene } from "./cinematic/types"; import { CaptionOverlay } from "./components/CaptionOverlay"; +import { resolveAsset } from "./lib/resolveAsset"; const FPS = 30; @@ -57,10 +39,11 @@ const toneGradient = (tone: CinematicTone) => { const SceneVideo: React.FC<{ scene: CinematicVideoScene }> = ({ scene }) => { const frame = useCurrentFrame(); - const { durationInFrames, fps } = useVideoConfig(); + const { fps } = useVideoConfig(); + const durationInFrames = Math.max(1, Math.round(scene.durationSeconds * fps)); const fadeInFrames = scene.fadeInFrames ?? 10; const fadeOutFrames = scene.fadeOutFrames ?? 10; - const fadeOutStart = Math.max(fadeInFrames, durationInFrames - fadeOutFrames); + const fadeOutStart = Math.max(0, durationInFrames - fadeOutFrames); const fadeInOpacity = fadeInFrames === 0 ? 1 @@ -98,6 +81,7 @@ const SceneVideo: React.FC<{ scene: CinematicVideoScene }> = ({ scene }) => { src={resolveAsset(scene.src)} trimBefore={trimBefore} trimAfter={trimAfter} + playbackRate={scene.playbackRate} style={{ width: "100%", height: "100%", diff --git a/remotion-composer/src/CollageBurst.tsx b/remotion-composer/src/CollageBurst.tsx index b0400095..77cf0c74 100644 --- a/remotion-composer/src/CollageBurst.tsx +++ b/remotion-composer/src/CollageBurst.tsx @@ -6,12 +6,12 @@ import { interpolate, random, spring, - staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import React from "react"; import { loadFont as loadPlayfair } from "@remotion/google-fonts/PlayfairDisplay"; +import { resolveAsset } from "./lib/resolveAsset"; const { fontFamily: playfairFamily } = loadPlayfair("normal", { weights: ["400", "700"], @@ -22,23 +22,6 @@ const { fontFamily: playfairItalic } = loadPlayfair("italic", { subsets: ["latin"], }); -function resolveAsset(src: string): string { - if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) return src; - const clean = src.replace(/^file:\/\/\/?/, ""); - if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { - const posix = clean.replace(/\\/g, "/"); - // POSIX absolute paths already have a leading "/" — file:// + posix - // gives exactly three slashes. Windows drive paths (C:/...) need the - // extra slash added explicitly. Do not merge these branches — adding - // "file:///" unconditionally double-slashes POSIX paths (file:////...). - if (posix.startsWith("/")) { - return `file://${posix}`; - } - return `file:///${posix}`; - } - return staticFile(clean); -} - export type CollageTransition = | "pop" | "slide-zoom" @@ -63,13 +46,13 @@ export interface CollageClip { seed?: number; } -export interface CollageBurstProps { +export type CollageBurstProps = { backgroundSrc: string; backgroundInSeconds?: number; curtainStartSeconds: number; curtainEndSeconds: number; clips: CollageClip[]; -} +}; // ---------------------------------------------------------------------------- // Opening text — elegant serif card that lives in the pre-reveal black, then diff --git a/remotion-composer/src/Explainer.tsx b/remotion-composer/src/Explainer.tsx index cf7ae03f..91b0da67 100644 --- a/remotion-composer/src/Explainer.tsx +++ b/remotion-composer/src/Explainer.tsx @@ -6,34 +6,10 @@ import { Sequence, interpolate, spring, - staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import { loadFont } from "@remotion/google-fonts/SpaceGrotesk"; - -// Resolve asset path — handle URLs, absolute paths (Windows/Unix), and public/ relative paths -function resolveAsset(src: string): string { - if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) { - return src; - } - // Strip any file:// prefix - const clean = src.replace(/^file:\/\/\/?/, ""); - // Absolute paths (Unix: /foo, Windows: C:\foo or C:/foo) — convert to file:// URI - // staticFile() only accepts relative paths within public/, so absolute paths must bypass it - if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { - const posix = clean.replace(/\\/g, "/"); - // POSIX absolute paths already have a leading "/" — file:// + posix - // gives exactly three slashes. Windows drive paths (C:/...) need the - // extra slash added explicitly. Do not merge these branches — adding - // "file:///" unconditionally double-slashes POSIX paths (file:////...). - if (posix.startsWith("/")) { - return `file://${posix}`; - } - return `file:///${posix}`; - } - return staticFile(clean); -} import { TextCard } from "./components/TextCard"; import { StatCard } from "./components/StatCard"; import { CalloutBox } from "./components/CalloutBox"; @@ -54,6 +30,7 @@ import type { TerminalStep } from "./components/TerminalScene"; import { ScreenshotScene } from "./components/ScreenshotScene"; import type { ScreenshotStep } from "./components/ScreenshotScene"; import { ProviderChip } from "./components/ProviderChip"; +import { resolveAsset } from "./lib/resolveAsset"; import type { ParticleType } from "./components/ParticleOverlay"; import { resolveTheme, type ThemeConfig, DEFAULT_THEME } from "./Root"; @@ -254,6 +231,7 @@ interface Cut { animation?: string; transition_in?: string; transition_out?: string; + transition_duration?: number; transform?: { animation?: string; scale?: number; @@ -425,22 +403,49 @@ const ImageScene: React.FC<{ src: string; animation?: string }> = ({ // Enhanced Video Scene // --------------------------------------------------------------------------- -const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({ +const VideoScene: React.FC<{ + src: string; + startFrom?: number; + transitionIn?: string; + transitionOut?: string; + transitionDuration?: number; + sceneDurationSeconds: number; + backgroundColor?: string; +}> = ({ src, startFrom = 0, + transitionIn, + transitionOut, + transitionDuration, + sceneDurationSeconds, + backgroundColor = "#0F172A", }) => { const frame = useCurrentFrame(); - const { fps, durationInFrames } = useVideoConfig(); + const { fps } = useVideoConfig(); + const durationInFrames = Math.max(1, Math.round(sceneDurationSeconds * fps)); - const fadeIn = spring({ frame, fps, config: { damping: 20 } }); - const fadeOutStart = durationInFrames - 8; - const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); + const hardIn = ["cut", "none"].includes((transitionIn || "").toLowerCase()); + const hardOut = ["cut", "none"].includes((transitionOut || "").toLowerCase()); + const transitionFrames = Math.max( + 1, + Math.round((transitionDuration ?? 8 / fps) * fps), + ); + const fadeIn = hardIn + ? 1 + : interpolate(frame, [0, transitionFrames], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const fadeOutStart = Math.max(0, durationInFrames - transitionFrames); + const fadeOut = hardOut + ? 1 + : interpolate(frame, [fadeOutStart, durationInFrames], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); return ( - + = ({ cut, theme } if (cut.source && isVideo(cut.source)) { - return maybeWrapWithBg(); + return maybeWrapWithBg( + , + ); } // Final fallback — try as image if source exists, otherwise show text_card @@ -741,7 +756,7 @@ const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => { if (overlay.type === "section_title") { return ( = ({ overlay }) => { if (overlay.type === "stat_reveal") { return ( = ({ overlay }) => { ); } if (overlay.type === "hero_title") { - return ; + return ; } if (overlay.type === "provider_chip" && overlay.providers) { return ( diff --git a/remotion-composer/src/LyricOverlay.tsx b/remotion-composer/src/LyricOverlay.tsx index 4896cf1e..287110f7 100644 --- a/remotion-composer/src/LyricOverlay.tsx +++ b/remotion-composer/src/LyricOverlay.tsx @@ -3,46 +3,29 @@ import { Audio, OffthreadVideo, interpolate, - staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import React from "react"; import { loadFont as loadPlayfair } from "@remotion/google-fonts/PlayfairDisplay"; +import { resolveAsset } from "./lib/resolveAsset"; const { fontFamily: playfairItalic } = loadPlayfair("italic", { weights: ["400", "700"], subsets: ["latin"], }); -function resolveAsset(src: string): string { - if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) return src; - const clean = src.replace(/^file:\/\/\/?/, ""); - if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { - const posix = clean.replace(/\\/g, "/"); - // POSIX absolute paths already have a leading "/" — file:// + posix - // gives exactly three slashes. Windows drive paths (C:/...) need the - // extra slash added explicitly. Do not merge these branches — adding - // "file:///" unconditionally double-slashes POSIX paths (file:////...). - if (posix.startsWith("/")) { - return `file://${posix}`; - } - return `file:///${posix}`; - } - return staticFile(clean); -} - export interface Lyric { text: string; inSeconds: number; outSeconds: number; } -export interface LyricOverlayProps { +export type LyricOverlayProps = { videoSrc: string; lyrics: Lyric[]; bottomY?: number; // 0..1, vertical center of subtitle band -} +}; const LyricLine: React.FC<{ lyric: Lyric; bottomY: number }> = ({ lyric, bottomY }) => { const frame = useCurrentFrame(); diff --git a/remotion-composer/src/TitledVideo.tsx b/remotion-composer/src/TitledVideo.tsx index 0129271c..0f710cae 100644 --- a/remotion-composer/src/TitledVideo.tsx +++ b/remotion-composer/src/TitledVideo.tsx @@ -5,12 +5,12 @@ import { Sequence, interpolate, spring, - staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import { getVideoMetadata } from "@remotion/media-utils"; import { loadFont } from "@remotion/google-fonts/PlayfairDisplay"; +import { resolveAsset } from "./lib/resolveAsset"; // Editorial serif for the tagline — Playfair Display at its boldest weight. // Loaded once at module scope so every render reuses the same font face. @@ -19,7 +19,7 @@ const { fontFamily } = loadFont("normal", { subsets: ["latin"], }); -export interface TitledVideoProps { +export type TitledVideoProps = { videoSrc: string; tagline: string; // When the tagline starts animating in, in seconds from the start of the video. @@ -32,33 +32,10 @@ export interface TitledVideoProps { fontSize?: number; // Accent color used for the underline and the glow halo. accentColor?: string; -} +}; // Resolve asset path — handle URLs, absolute paths, and public/ relative paths. // Mirrors the helper in Explainer.tsx so absolute Windows/Unix paths work. -function resolveAsset(src: string): string { - if ( - src.startsWith("http://") || - src.startsWith("https://") || - src.startsWith("data:") - ) { - return src; - } - const clean = src.replace(/^file:\/\/\/?/, ""); - if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { - const posix = clean.replace(/\\/g, "/"); - // POSIX absolute paths already have a leading "/" — file:// + posix - // gives exactly three slashes. Windows drive paths (C:/...) need the - // extra slash added explicitly. Do not merge these branches — adding - // "file:///" unconditionally double-slashes POSIX paths (file:////...). - if (posix.startsWith("/")) { - return `file://${posix}`; - } - return `file:///${posix}`; - } - return staticFile(clean); -} - // --------------------------------------------------------------------------- // EditorialTagline — big bold serif, upper-third, drawn underline, warm glow. // Letter-by-letter spring entrance. Designed to feel like a printed headline, diff --git a/remotion-composer/src/cinematic/types.ts b/remotion-composer/src/cinematic/types.ts index 67d8689b..7669968d 100644 --- a/remotion-composer/src/cinematic/types.ts +++ b/remotion-composer/src/cinematic/types.ts @@ -12,6 +12,7 @@ export interface CinematicVideoScene extends CinematicBaseScene { tone?: CinematicTone; trimBeforeSeconds?: number; trimAfterSeconds?: number; + playbackRate?: number; filter?: string; fadeInFrames?: number; fadeOutFrames?: number; diff --git a/remotion-composer/src/components/AnimeScene.tsx b/remotion-composer/src/components/AnimeScene.tsx index acfcd694..c2ab1741 100644 --- a/remotion-composer/src/components/AnimeScene.tsx +++ b/remotion-composer/src/components/AnimeScene.tsx @@ -3,27 +3,11 @@ import { Img, interpolate, spring, - staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import { ParticleOverlay, type ParticleType } from "./ParticleOverlay"; - -/** - * Resolve asset path — use staticFile() for local paths, passthrough URLs. - * Duplicated from Explainer.tsx to keep the component self-contained. - */ -function resolveAsset(src: string): string { - if ( - src.startsWith("http://") || - src.startsWith("https://") || - src.startsWith("data:") - ) { - return src; - } - const clean = src.replace(/^file:\/\/\/?/, ""); - return staticFile(clean); -} +import { resolveAsset } from "../lib/resolveAsset"; // --------------------------------------------------------------------------- // Types diff --git a/remotion-composer/src/components/CaptionOverlay.tsx b/remotion-composer/src/components/CaptionOverlay.tsx index e31123c6..1f9cf207 100644 --- a/remotion-composer/src/components/CaptionOverlay.tsx +++ b/remotion-composer/src/components/CaptionOverlay.tsx @@ -14,7 +14,7 @@ export interface WordCaption { endMs: number; } -interface CaptionOverlayProps { +type CaptionOverlayProps = { words: WordCaption[]; // How many words to show at once in a "page" wordsPerPage?: number; @@ -23,7 +23,7 @@ interface CaptionOverlayProps { highlightColor?: string; backgroundColor?: string; fontFamily?: string; -} +}; interface CaptionPage { words: WordCaption[]; diff --git a/remotion-composer/src/components/EndTag.tsx b/remotion-composer/src/components/EndTag.tsx index 6ffbfefb..ab785c51 100644 --- a/remotion-composer/src/components/EndTag.tsx +++ b/remotion-composer/src/components/EndTag.tsx @@ -6,7 +6,7 @@ import { useVideoConfig, } from "remotion"; -export interface EndTagProps { +export type EndTagProps = { text: string; palette?: "cool_offwhite_on_black" | "warm_ivory_on_black"; // Optional extra fade hold controls (all in seconds) @@ -19,7 +19,7 @@ export interface EndTagProps { // AbsoluteFill drops its background fill — caller is responsible for // rendering with an alpha-capable codec (VP9/WebM or ProRes 4444). overlay?: boolean; -} +}; const PALETTES = { cool_offwhite_on_black: { diff --git a/remotion-composer/src/components/HeroTitle.tsx b/remotion-composer/src/components/HeroTitle.tsx index 381dd173..d4b6321d 100644 --- a/remotion-composer/src/components/HeroTitle.tsx +++ b/remotion-composer/src/components/HeroTitle.tsx @@ -6,10 +6,10 @@ import { useVideoConfig, } from "remotion"; -interface HeroTitleProps { +type HeroTitleProps = { title: string; subtitle?: string; -} +}; export const HeroTitle: React.FC = ({ title, subtitle }) => { const frame = useCurrentFrame(); diff --git a/remotion-composer/src/components/ProductReveal.tsx b/remotion-composer/src/components/ProductReveal.tsx index c8967ab9..4ad81dba 100644 --- a/remotion-composer/src/components/ProductReveal.tsx +++ b/remotion-composer/src/components/ProductReveal.tsx @@ -9,14 +9,14 @@ import { Easing, } from "remotion"; -export interface ProductRevealProps { +export type ProductRevealProps = { productImage: string; productName: string; price: string; tagline: string; closer: string; accentColor?: string; -} +}; export const ProductReveal: React.FC = ({ productImage, diff --git a/remotion-composer/src/components/ProviderChip.tsx b/remotion-composer/src/components/ProviderChip.tsx index fdc69372..0f11230a 100644 --- a/remotion-composer/src/components/ProviderChip.tsx +++ b/remotion-composer/src/components/ProviderChip.tsx @@ -53,7 +53,7 @@ export const ProviderChip: React.FC = ({ const translateY = interpolate(springIn, [0, 1], [12, 0]); return ( - +
    + src.startsWith("http://") || + src.startsWith("https://") || + src.startsWith("data:"); + +const isWindowsAbsolutePath = (src: string): boolean => + /^[A-Za-z]:[\\/]/.test(src); + +/** Resolve public assets and absolute filesystem paths consistently. */ +export function resolveAsset(src: string): string { + if (isRemoteAsset(src)) { + return src; + } + + const withoutScheme = src.replace(/^file:\/\//i, ""); + const clean = /^\/[A-Za-z]:[\\/]/.test(withoutScheme) + ? withoutScheme.slice(1) + : withoutScheme; + + if (clean.startsWith("/") || isWindowsAbsolutePath(clean)) { + const posix = clean.replace(/\\/g, "/"); + return posix.startsWith("/") ? `file://${posix}` : `file:///${posix}`; + } + + return staticFile(clean); +} diff --git a/schemas/artifacts/__init__.py b/schemas/artifacts/__init__.py index acf15edf..147b2093 100644 --- a/schemas/artifacts/__init__.py +++ b/schemas/artifacts/__init__.py @@ -39,7 +39,7 @@ def load_schema(name: str) -> dict: path = SCHEMA_DIR / f"{name}.schema.json" if not path.exists(): raise FileNotFoundError(f"Schema not found: {path}") - with open(path) as f: + with open(path, encoding="utf-8") as f: return json.load(f) diff --git a/schemas/artifacts/edit_decisions.schema.json b/schemas/artifacts/edit_decisions.schema.json index 63eeca4f..b4dc0089 100644 --- a/schemas/artifacts/edit_decisions.schema.json +++ b/schemas/artifacts/edit_decisions.schema.json @@ -57,6 +57,10 @@ "minimum": 0, "description": "Duration of transition in seconds" }, + "backgroundColor": { + "type": "string", + "description": "Backing color used behind this cut by composition runtimes" + }, "reason": { "type": "string" } }, "additionalProperties": false diff --git a/schemas/pipelines/pipeline_manifest.schema.json b/schemas/pipelines/pipeline_manifest.schema.json index ebf73b40..a7737e43 100644 --- a/schemas/pipelines/pipeline_manifest.schema.json +++ b/schemas/pipelines/pipeline_manifest.schema.json @@ -11,7 +11,7 @@ "description": { "type": "string" }, "category": { "type": "string", - "enum": ["talking_head", "generated", "hybrid", "screen_recording", "animation", "cinematic", "custom"] + "enum": ["talking_head", "generated", "hybrid", "screen_recording", "animation", "cinematic", "documentary", "custom"] }, "stability": { "type": "string", diff --git a/scripts/backlot_screenshot_stage.py b/scripts/backlot_screenshot_stage.py index 0230a6f6..e968d5a9 100644 --- a/scripts/backlot_screenshot_stage.py +++ b/scripts/backlot_screenshot_stage.py @@ -184,6 +184,15 @@ def stage_project(pid: str, title: str, palette: str, scenes: list, *, brief["topic"] = title cp("research", "completed", {"research_brief": brief}) + proposal = sample_artifact("proposal_packet") + cp("proposal", "awaiting_human", {"proposal_packet": proposal}) + cp( + "proposal", + "completed", + {"proposal_packet": proposal}, + human_approved=True, + ) + script = script_artifact(title, scenes) plan = scene_plan_artifact(scenes, hero) (art_dir / "decision_log.json").write_text(json.dumps(decision_log(pid), indent=2)) diff --git a/skills/INDEX.md b/skills/INDEX.md index 228cd62f..01481798 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -60,7 +60,9 @@ Key capability families to look for in the output: | `analysis` | — | Mixed providers | | `character_animation` | — | Local character specs, SVG rigs, pose libraries, action timelines, previews, and QA | | `graphics` | — | Local rendering tools | -| `music_generation` | — | Single-provider | +| `music_library` | — | Discovers user-provided local tracks | +| `music_search` | — | Discovers royalty-free search/download providers | +| `music_generation` | — | Discovers paid/local generation providers | | `subtitle` | — | Pure Python | | `avatar` | — | Local GPU models | | `video_post` | — | FFmpeg-based local tools | diff --git a/skills/pipelines/cinematic/idea-director.md b/skills/pipelines/cinematic/idea-director.md index 0f332c81..d6cbc7ce 100644 --- a/skills/pipelines/cinematic/idea-director.md +++ b/skills/pipelines/cinematic/idea-director.md @@ -88,23 +88,26 @@ Cinematic videos live and die by their audio. **Surface the music situation befo Check availability in this order: -1. **User music library (`music_library/`):** Check if this folder exists and contains tracks. List available tracks with durations and moods. Let the user choose. -2. **Music generation APIs:** Check `registry.get_by_capability("music_generation")`. Report status, quota, and cost per track. -3. **Royalty-free sources:** Note that the user can provide a track from YouTube Audio Library, Jamendo, or other free sources by dropping it in `music_library/`. +1. **User music library:** Check `registry.get_by_capability("music_library")` and inspect `music_library/`. List available tracks with durations and moods. +2. **Royalty-free search:** Check `registry.get_by_capability("music_search")`. Report available providers, licensing constraints, and any key requirement. +3. **Music generation APIs:** Check `registry.get_by_capability("music_generation")`. Report status, quota, cost, and quality tradeoffs. +4. **Bring your own:** The user can drop a track from another licensed source into `music_library/`. Present explicit options: ``` MUSIC PLAN ├── Your music library: [N tracks / empty] +├── Royalty-free search: [providers / unavailable] ├── AI generation: [provider] — [AVAILABLE/UNAVAILABLE] [cost] └── Bring your own: Drop a track in music_library/ before asset stage Options: (a) Use a library track (which one?) - (b) Provide your own track - (c) Generate via API (if available) - (d) Proceed without music (not recommended for cinematic) + (b) Search a royalty-free provider + (c) Provide your own track + (d) Generate via API (if available) + (e) Proceed without music (not recommended for cinematic) ``` Record the decision in `brief.metadata.music_strategy` with the chosen source and path/prompt. diff --git a/skills/pipelines/cinematic/proposal-director.md b/skills/pipelines/cinematic/proposal-director.md index 1bbdcbd4..13d291e1 100644 --- a/skills/pipelines/cinematic/proposal-director.md +++ b/skills/pipelines/cinematic/proposal-director.md @@ -222,14 +222,16 @@ Let the user select, combine, modify, or redirect entirely. Cinematic videos live and die by their audio. Surface the music situation before the user approves. Check availability in this order: -1. **User music library (`music_library/`)** — list available tracks -2. **Music generation APIs** — report status, cost, and quality honestly -3. **Bring-your-own path** — user can drop a track in `music_library/` +1. **User music library** — query `registry.get_by_capability("music_library")` and list available tracks +2. **Royalty-free search** — query `registry.get_by_capability("music_search")` and report providers/licensing +3. **Music generation APIs** — query `registry.get_by_capability("music_generation")` and report status, cost, and quality honestly +4. **Bring-your-own path** — user can drop a track in `music_library/` Present explicit options: ``` MUSIC PLAN ├── Your music library: [N tracks / empty] +├── Royalty-free search: [providers / unavailable] ├── AI generation: [provider] — [AVAILABLE/UNAVAILABLE] [cost] └── Bring your own: Drop a track in music_library/ before asset stage diff --git a/skills/pipelines/documentary-montage/idea-director.md b/skills/pipelines/documentary-montage/idea-director.md index 204187d2..4e4ab81e 100644 --- a/skills/pipelines/documentary-montage/idea-director.md +++ b/skills/pipelines/documentary-montage/idea-director.md @@ -89,11 +89,14 @@ like abandoned footage at compose time. Do not assume silence will earn itself. If the user has not mentioned music, ASSUME THEY WANT IT and pick: - user-provided track (put path in `music_plan.source_path`), -- music library pick (list what's in `music_library/`), +- music library pick (query `registry.get_by_capability("music_library")` and list tracks), +- royalty-free search (query `registry.get_by_capability("music_search")`, report provider and license), - generated (name the tool and prompt seed with register), - explicit opt-out (`source: "none"` + `opt_out_reason`). -**Warn the user if no music source is available.** Do not silently +Before declaring no source available, also query +`registry.get_by_capability("music_generation")`. **Warn the user if no music +source is available.** Do not silently defer this — it becomes an expensive surprise at the asset stage. ### 5. Note End-Tag Intent (MANDATORY) diff --git a/skills/pipelines/explainer/idea-director.md b/skills/pipelines/explainer/idea-director.md index f500804b..5dda09b9 100644 --- a/skills/pipelines/explainer/idea-director.md +++ b/skills/pipelines/explainer/idea-director.md @@ -129,7 +129,9 @@ If any dimension scores below 3, iterate before submitting. The reviewer will ch ### Step 7: Submit -Call `handle_explainer_idea(state, {"brief": brief_json})` to validate and persist. +Validate `brief_json` against the canonical brief schema, persist it through +the checkpoint protocol, and attach the stage review. There is no separate +explainer submit function. ## Playbook Selection Guide diff --git a/skills/pipelines/explainer/proposal-director.md b/skills/pipelines/explainer/proposal-director.md index db718b72..ffb482eb 100644 --- a/skills/pipelines/explainer/proposal-director.md +++ b/skills/pipelines/explainer/proposal-director.md @@ -369,9 +369,9 @@ Music is a critical part of the video's feel. **Surface the music situation to t **Check music availability in this order:** -1. **User music library (`music_library/`):** Check if this folder exists and contains tracks. If so, list available tracks with durations and let the user pick one. -2. **Music generation APIs:** Check which music tools are available via the registry (`registry.get_by_capability("music_generation")`). Report their status honestly. -3. **Stock music sources:** Note if stock music is available via any provider. +1. **User music library:** Check `registry.get_by_capability("music_library")` and inspect `music_library/`. List tracks with durations. +2. **Royalty-free search:** Check `registry.get_by_capability("music_search")`. Report providers, licensing constraints, and key requirements. +3. **Music generation APIs:** Check `registry.get_by_capability("music_generation")`. Report status, quota, cost, and quality tradeoffs honestly. **Present to the user:** @@ -381,15 +381,17 @@ MUSIC PLAN │ ├── cosmic_interstellar_space.mp3 (3:13) — ambient, cosmic │ ├── cinematic_epic.mp3 (2:45) — dramatic, building │ └── lofi_beat.mp3 (4:00) — chill, electronic +├── Royalty-free search: [providers / unavailable] ├── AI generation: music_gen (ElevenLabs) — UNAVAILABLE (plan limit) └── Recommendation: Use "cosmic_interstellar_space.mp3" from your library OR provide a different track before asset generation Would you like to: (a) Use a track from your library (which one?) - (b) Provide a different track (drop it in music_library/) - (c) Generate one via API (if available) - (d) Proceed without music + (b) Search a royalty-free provider + (c) Provide a different track (drop it in music_library/) + (d) Generate one via API (if available) + (e) Proceed without music ``` **If no music source is available:** Tell the user explicitly. Do NOT let this surface as a surprise at the asset stage. Offer the `music_library/` path so they can add a track before production starts. diff --git a/skills/pipelines/explainer/scene-director.md b/skills/pipelines/explainer/scene-director.md index 9642d666..131eb455 100644 --- a/skills/pipelines/explainer/scene-director.md +++ b/skills/pipelines/explainer/scene-director.md @@ -63,9 +63,17 @@ Transform each script section into 1-3 visual scenes. Each scene is a distinct v } ``` -#### Scene Types and When to Use Them +#### Render Templates and Scene Types -| Type | Best For | Available Tools | Duration Guidance | +The canonical `scene_plan.scenes[].type` vocabulary is `talking_head`, `broll`, +`animation`, `character_scene`, `diagram`, `text_card`, `transition`, +`generated`, and `screen_recording`. Names such as `hero_title`, `stat_card`, +and chart/card variants below are downstream Remotion `cut.type` templates, +not valid scene-plan types. During scene planning, use `text_card` or +`animation` as appropriate and state the intended render template in the scene +description; the Edit stage converts that intent into `cut.type`. + +| Render template or scene type | Best For | Available Tools | Duration Guidance | |------|----------|-----------------|-------------------| | `hero_title` | Opening titles, dramatic reveals | Remotion HeroTitle (theme-driven title treatment) | 3-5s | | `stat_card` | Big dramatic numbers, impactful metrics | Remotion StatCard (large stat + subtitle) | 4-6s | @@ -84,7 +92,11 @@ Transform each script section into 1-3 visual scenes. Each scene is a distinct v | `broll` | Context, real-world examples | Stock or generated footage | 3-6s | | `screen_recording` | Code demos, UI walkthroughs | Recorded or simulated | 5-15s | -**Zero-key scene selection:** When no image/video generation is available, prefer `hero_title`, `stat_card`, `bar_chart`, `line_chart`, `pie_chart`, `kpi_grid`, `comparison`, `callout`, `progress_bar`, and `text_card`. These render entirely from Remotion components with zero external dependencies and can still feel distinct if you derive color, typography, and pacing from the subject instead of defaulting to a generic dashboard aesthetic. +**Zero-key scene selection:** When no image/video generation is available, +plan `text_card`, `animation`, or `diagram` scenes and name an appropriate +downstream template (`hero_title`, `stat_card`, charts, `comparison`, +`callout`, or `progress_bar`) in each description. These render entirely from +Remotion components with zero external dependencies. ### Step 4: Apply the Visual Technique Library @@ -226,7 +238,9 @@ If any dimension scores below 3, revise. ### Step 8: Submit -Call `handle_explainer_scene_plan(state, {"scene_plan": scene_plan_json})` to validate and persist. +Validate `scene_plan_json` against the canonical scene-plan schema, persist it +through the checkpoint protocol, and attach the stage review. There is no +separate explainer submit function. ## Common Pitfalls diff --git a/skills/pipelines/explainer/script-director.md b/skills/pipelines/explainer/script-director.md index f00404c8..f33a9e93 100644 --- a/skills/pipelines/explainer/script-director.md +++ b/skills/pipelines/explainer/script-director.md @@ -206,7 +206,9 @@ If any dimension scores below 3, revise before submitting. ### Step 7: Submit -Call `handle_explainer_script(state, {"script": script_json})` to validate and persist. +Validate `script_json` against the canonical script schema, persist it through +the checkpoint protocol, and attach the stage review. There is no separate +explainer submit function. ### Mid-Production Fact Verification diff --git a/styles/playbook_loader.py b/styles/playbook_loader.py index 0ece61f6..70155eb4 100644 --- a/styles/playbook_loader.py +++ b/styles/playbook_loader.py @@ -26,7 +26,7 @@ SCHEMA_PATH = ( def _load_playbook_schema() -> dict: - with open(SCHEMA_PATH) as f: + with open(SCHEMA_PATH, encoding="utf-8") as f: return json.load(f) @@ -45,7 +45,7 @@ def load_playbook(name: str, styles_dir: Optional[Path] = None) -> dict[str, Any if not path.exists(): raise FileNotFoundError(f"Playbook not found: {path}") - with open(path) as f: + with open(path, encoding="utf-8") as f: playbook = yaml.safe_load(f) validate_playbook(playbook) diff --git a/tests/backlot/test_gate_scenarios.py b/tests/backlot/test_gate_scenarios.py index d00920c3..6d96c4de 100644 --- a/tests/backlot/test_gate_scenarios.py +++ b/tests/backlot/test_gate_scenarios.py @@ -7,7 +7,11 @@ import pytest from backlot import state as state_mod from backlot.state import load_board_state -from lib.checkpoint import CheckpointValidationError, write_checkpoint +from lib.checkpoint import ( + CANONICAL_STAGE_ARTIFACTS, + CheckpointValidationError, + write_checkpoint, +) def _script_artifact() -> dict: @@ -23,6 +27,22 @@ def _manifest_artifact() -> dict: return {"version": "1.0", "assets": [], "total_cost_usd": 0.0} +def _approve_predecessors(tmp_path, project_id, pipeline_type, *stages) -> None: + from tests.contracts.test_phase0_contracts import sample_artifact + + for stage in stages: + artifact_name = CANONICAL_STAGE_ARTIFACTS[stage] + write_checkpoint( + tmp_path, + project_id, + stage, + "completed", + {artifact_name: sample_artifact(artifact_name)}, + pipeline_type=pipeline_type, + human_approved=True, + ) + + def _write(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data), encoding="utf-8") @@ -73,6 +93,15 @@ def test_handwritten_completed_checkpoint_surfaces_gate_skip(tmp_path, monkeypat def test_awaiting_then_approved_archives_history_without_gate_skip(tmp_path): + _approve_predecessors( + tmp_path, + "film", + "cinematic", + "research", + "proposal", + "script", + "scene_plan", + ) write_checkpoint( tmp_path, "film", diff --git a/tests/backlot/test_ui_bug_bash.py b/tests/backlot/test_ui_bug_bash.py index 8f5cf3e5..7c1e3458 100644 --- a/tests/backlot/test_ui_bug_bash.py +++ b/tests/backlot/test_ui_bug_bash.py @@ -10,7 +10,8 @@ import urllib.request import pytest -from lib.checkpoint import init_project, write_checkpoint +from lib.checkpoint import CANONICAL_STAGE_ARTIFACTS, init_project, write_checkpoint +from lib.pipeline_loader import get_stage_order, load_pipeline from scripts import backlot_screenshot_stage from tests.contracts.test_phase0_contracts import sample_artifact @@ -32,6 +33,28 @@ APPROVAL_CASES = [ ] +def _complete_predecessors(root, project_id: str, pipeline_type: str, stage: str) -> None: + order = get_stage_order(load_pipeline(pipeline_type)) + for predecessor in order[: order.index(stage)]: + artifact_name = CANONICAL_STAGE_ARTIFACTS.get(predecessor) + if artifact_name: + artifact = sample_artifact(artifact_name) + if artifact_name == "edit_decisions": + artifact["render_runtime"] = "ffmpeg" + artifacts = {artifact_name: artifact} + else: + artifacts = {} + write_checkpoint( + root, + project_id, + predecessor, + "completed", + artifacts, + pipeline_type=pipeline_type, + human_approved=True, + ) + + def _build_approval_projects() -> None: root = backlot_screenshot_stage.STAGE_DIR for project_id, pipeline_type, stage, artifact_name, _visible_text in APPROVAL_CASES: @@ -55,6 +78,7 @@ def _build_approval_projects() -> None: pipeline_type=pipeline_type, pipeline_dir=root, ) + _complete_predecessors(root, project_id, pipeline_type, stage) write_checkpoint( root, project_id, @@ -80,6 +104,12 @@ def _build_approval_projects() -> None: pipeline_type="character-animation", pipeline_dir=root, ) + _complete_predecessors( + root, + "gate-character-design", + "character-animation", + "character_design", + ) write_checkpoint( root, "gate-character-design", diff --git a/tests/contracts/test_agent_instruction_integrity.py b/tests/contracts/test_agent_instruction_integrity.py new file mode 100644 index 00000000..82443ab5 --- /dev/null +++ b/tests/contracts/test_agent_instruction_integrity.py @@ -0,0 +1,31 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _read(relative_path: str) -> str: + return (REPO_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_music_plans_discover_all_music_capabilities() -> None: + instruction_files = [ + "AGENT_GUIDE.md", + "skills/pipelines/cinematic/idea-director.md", + "skills/pipelines/cinematic/proposal-director.md", + "skills/pipelines/documentary-montage/idea-director.md", + "skills/pipelines/explainer/proposal-director.md", + ] + + for relative_path in instruction_files: + text = _read(relative_path) + for capability in ("music_library", "music_search", "music_generation"): + assert f'get_by_capability("{capability}")' in text, ( + f"{relative_path} omits the {capability!r} music source" + ) + + +def test_explainer_directors_do_not_reference_fictitious_submit_functions() -> None: + for stage in ("idea", "script", "scene"): + text = _read(f"skills/pipelines/explainer/{stage}-director.md") + assert "handle_explainer_" not in text diff --git a/tests/contracts/test_backlot_contract.py b/tests/contracts/test_backlot_contract.py index 50245baa..82a257fc 100644 --- a/tests/contracts/test_backlot_contract.py +++ b/tests/contracts/test_backlot_contract.py @@ -6,6 +6,7 @@ import json import pytest from lib.checkpoint import ( + CANONICAL_STAGE_ARTIFACTS, CheckpointValidationError, HISTORY_DIRNAME, PROJECT_MARKER_FILENAME, @@ -27,6 +28,22 @@ def _minimal_script() -> dict: } +def _approve_predecessors(tmp_path, project_id, pipeline_type, *stages) -> None: + from tests.contracts.test_phase0_contracts import sample_artifact + + for stage in stages: + artifact_name = CANONICAL_STAGE_ARTIFACTS[stage] + write_checkpoint( + tmp_path, + project_id, + stage, + "completed", + artifacts={artifact_name: sample_artifact(artifact_name)}, + pipeline_type=pipeline_type, + human_approved=True, + ) + + class TestGateEnforcement: """GI-4: gated stages cannot be completed without approval evidence.""" @@ -39,6 +56,9 @@ class TestGateEnforcement: ) def test_awaiting_human_is_the_correct_gate_state(self, tmp_path): + _approve_predecessors( + tmp_path, "proj", "animated-explainer", "research", "proposal" + ) path = write_checkpoint( tmp_path, "proj", "script", "awaiting_human", artifacts={"script": _minimal_script()}, @@ -51,6 +71,9 @@ class TestGateEnforcement: assert cp["human_approval_required"] is True def test_completed_with_approval_passes(self, tmp_path): + _approve_predecessors( + tmp_path, "proj", "animated-explainer", "research", "proposal" + ) path = write_checkpoint( tmp_path, "proj", "script", "completed", artifacts={"script": _minimal_script()}, @@ -84,6 +107,9 @@ class TestCheckpointHistory: """Superseded checkpoints are archived, not destroyed.""" def test_overwrite_archives_previous(self, tmp_path): + _approve_predecessors( + tmp_path, "proj", "animated-explainer", "research", "proposal" + ) write_checkpoint( tmp_path, "proj", "script", "awaiting_human", artifacts={"script": _minimal_script()}, diff --git a/tests/contracts/test_env_example.py b/tests/contracts/test_env_example.py new file mode 100644 index 00000000..a2dd245d --- /dev/null +++ b/tests/contracts/test_env_example.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from dotenv import dotenv_values + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_env_example_does_not_turn_comments_into_credentials() -> None: + """A fresh copy of .env.example must leave every documented key unset.""" + + values = dotenv_values(REPO_ROOT / ".env.example") + false_credentials = { + key: value + for key, value in values.items() + if isinstance(value, str) and value.lstrip().startswith("#") + } + + assert false_credentials == {} diff --git a/tests/contracts/test_phase2_contracts.py b/tests/contracts/test_phase2_contracts.py index 6c76931f..9c0746cf 100644 --- a/tests/contracts/test_phase2_contracts.py +++ b/tests/contracts/test_phase2_contracts.py @@ -174,10 +174,14 @@ class TestPhase2ErrorHandling: # Either succeeds (provider available) or fails gracefully assert isinstance(r, ToolResult) - def test_diagram_gen_empty_boxes(self): + def test_diagram_gen_empty_boxes(self, tmp_path): tool = DiagramGen() if tool.get_status() == ToolStatus.AVAILABLE: - r = tool.execute({"diagram_type": "boxes", "boxes": []}) + r = tool.execute({ + "diagram_type": "boxes", + "boxes": [], + "output_path": str(tmp_path / "empty-boxes.png"), + }) assert isinstance(r, ToolResult) diff --git a/tests/contracts/test_phase3_contracts.py b/tests/contracts/test_phase3_contracts.py index 3fa4a3b0..d582becd 100644 --- a/tests/contracts/test_phase3_contracts.py +++ b/tests/contracts/test_phase3_contracts.py @@ -9,6 +9,7 @@ import builtins import base64 import os import shutil +from types import SimpleNamespace from pathlib import Path from unittest.mock import MagicMock, patch @@ -571,16 +572,26 @@ class TestVeoVideo: called_kwargs = mock_client.models.generate_videos.call_args[1] assert called_kwargs["image"] is not None - def test_vertex_ai_mode_rejection(self): + def test_vertex_ai_mode_requires_inline_video_bytes(self): tool = VeoVideo() mock_client = MagicMock() mock_client.vertexai = True - if hasattr(mock_client, "_api_client"): - delattr(mock_client, "_api_client") + mock_client.models.generate_videos.return_value = SimpleNamespace( + done=True, + error=None, + response=SimpleNamespace( + generated_videos=[ + SimpleNamespace(video=SimpleNamespace(video_bytes=None)) + ] + ), + ) with ( patch.dict(os.environ, {"GEMINI_API_KEY": "test_key"}), - patch("google.genai.Client", return_value=mock_client), + patch( + "tools.google_credentials.get_genai_client", + return_value=mock_client, + ), ): inputs = { "prompt": "cinematic shot", @@ -589,7 +600,7 @@ class TestVeoVideo: res = tool.execute(inputs) assert res.success is False assert res.error is not None - assert "only supported using the Gemini Developer API" in res.error + assert "without inline bytes" in res.error def test_missing_local_image_paths(self): tool = VeoVideo() diff --git a/tests/contracts/test_pipeline_manifest_categories.py b/tests/contracts/test_pipeline_manifest_categories.py new file mode 100644 index 00000000..54983bb1 --- /dev/null +++ b/tests/contracts/test_pipeline_manifest_categories.py @@ -0,0 +1,9 @@ +"""Contracts for the category vocabulary used by shipped pipelines.""" + +from lib.pipeline_loader import load_pipeline + + +def test_documentary_pipeline_uses_a_schema_supported_category() -> None: + manifest = load_pipeline("documentary-montage") + + assert manifest["category"] == "documentary" diff --git a/tests/contracts/test_remotion_video_transition_contract.py b/tests/contracts/test_remotion_video_transition_contract.py new file mode 100644 index 00000000..3d30934b --- /dev/null +++ b/tests/contracts/test_remotion_video_transition_contract.py @@ -0,0 +1,28 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_video_scene_honors_hard_cut_tokens_and_backing_color() -> None: + source = (REPO_ROOT / "remotion-composer/src/Explainer.tsx").read_text( + encoding="utf-8" + ) + + assert '["cut", "none"].includes((transitionIn || "").toLowerCase())' in source + assert '["cut", "none"].includes((transitionOut || "").toLowerCase())' in source + assert "transitionIn={cut.transition_in}" in source + assert "transitionOut={cut.transition_out}" in source + assert "sceneDurationSeconds={cut.out_seconds - cut.in_seconds}" in source + assert "Math.round(sceneDurationSeconds * fps)" in source + assert "durationInFrames - transitionFrames" in source + assert "backgroundColor={cut.backgroundColor}" in source + + +def test_cinematic_fades_are_bounded_by_each_scene_duration() -> None: + source = (REPO_ROOT / "remotion-composer/src/CinematicRenderer.tsx").read_text( + encoding="utf-8" + ) + + assert "Math.round(scene.durationSeconds * fps)" in source + assert "durationInFrames - fadeOutFrames" in source diff --git a/tests/contracts/test_utf8_file_io.py b/tests/contracts/test_utf8_file_io.py new file mode 100644 index 00000000..d3a7ba30 --- /dev/null +++ b/tests/contracts/test_utf8_file_io.py @@ -0,0 +1,30 @@ +import ast +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +RUNTIME_FILES = [ + "lib/checkpoint.py", + "lib/pipeline_loader.py", + "schemas/artifacts/__init__.py", + "styles/playbook_loader.py", +] + + +@pytest.mark.parametrize("relative_path", RUNTIME_FILES) +def test_pipeline_contract_files_use_explicit_utf8(relative_path: str) -> None: + path = REPO_ROOT / relative_path + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + bare_opens = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Name) or node.func.id != "open": + continue + if not any(keyword.arg == "encoding" for keyword in node.keywords): + bare_opens.append(node.lineno) + + assert bare_opens == [], f"bare open() calls at lines {bare_opens}" diff --git a/tests/lib/test_checkpoint_prerequisites.py b/tests/lib/test_checkpoint_prerequisites.py new file mode 100644 index 00000000..ee9b5f97 --- /dev/null +++ b/tests/lib/test_checkpoint_prerequisites.py @@ -0,0 +1,152 @@ +import json + +import pytest +from tests.contracts.test_phase0_contracts import sample_artifact + +from lib.checkpoint import ( + CheckpointValidationError, + init_project, + write_checkpoint, +) + + +def _script_artifact() -> dict: + return { + "version": "1.0", + "title": "Smoke", + "total_duration_seconds": 1, + "sections": [ + { + "id": "s1", + "text": "One second.", + "start_seconds": 0, + "end_seconds": 1, + } + ], + } + + +def test_later_stage_cannot_skip_a_missing_predecessor(tmp_path) -> None: + init_project( + "run", + title="Run", + pipeline_type="framework-smoke", + pipeline_dir=tmp_path, + ) + + with pytest.raises(CheckpointValidationError, match="PREREQUISITE VIOLATION"): + write_checkpoint( + tmp_path, + "run", + "script", + "completed", + {"script": _script_artifact()}, + pipeline_type="framework-smoke", + human_approved=True, + ) + + +def test_later_stage_rejects_unapproved_gated_predecessor(tmp_path) -> None: + project_dir = init_project( + "run", + title="Run", + pipeline_type="framework-smoke", + pipeline_dir=tmp_path, + ) + predecessor_path = write_checkpoint( + tmp_path, + "run", + "research", + "awaiting_human", + {"research_brief": sample_artifact("research_brief")}, + pipeline_type="framework-smoke", + ) + predecessor = json.loads(predecessor_path.read_text(encoding="utf-8")) + predecessor["status"] = "completed" + predecessor["human_approved"] = False + predecessor_path.write_text(json.dumps(predecessor), encoding="utf-8") + + with pytest.raises(CheckpointValidationError, match="completed without required approval"): + write_checkpoint( + tmp_path, + "run", + "script", + "completed", + {"script": _script_artifact()}, + pipeline_type="framework-smoke", + human_approved=True, + ) + + +def test_malformed_predecessor_cannot_forge_completion(tmp_path) -> None: + project_dir = init_project( + "run", + title="Run", + pipeline_type="framework-smoke", + pipeline_dir=tmp_path, + ) + (project_dir / "checkpoint_research.json").write_text( + json.dumps({"status": "completed", "human_approved": True}), + encoding="utf-8", + ) + + with pytest.raises(CheckpointValidationError, match="incomplete or missing"): + write_checkpoint( + tmp_path, + "run", + "script", + "completed", + {"script": _script_artifact()}, + pipeline_type="framework-smoke", + human_approved=True, + ) + + +def test_in_progress_heartbeat_is_not_blocked_by_prerequisites(tmp_path) -> None: + init_project( + "run", + title="Run", + pipeline_type="framework-smoke", + pipeline_dir=tmp_path, + ) + + path = write_checkpoint( + tmp_path, + "run", + "script", + "in_progress", + {}, + pipeline_type="framework-smoke", + ) + + assert path.exists() + + +def test_unknown_style_playbook_fails_before_project_creation(tmp_path) -> None: + with pytest.raises(CheckpointValidationError, match="style_playbook"): + init_project( + "run", + title="Run", + pipeline_type="framework-smoke", + pipeline_dir=tmp_path, + style_playbook="does-not-exist", + ) + + assert not (tmp_path / "run").exists() + + +def test_marker_derived_unknown_playbook_blocks_later_writes(tmp_path) -> None: + project_dir = tmp_path / "run" + project_dir.mkdir() + (project_dir / "project.json").write_text( + json.dumps({ + "version": "1.0", + "project_id": "run", + "pipeline_type": "framework-smoke", + "style_playbook": "does-not-exist", + }), + encoding="utf-8", + ) + + with pytest.raises(CheckpointValidationError, match="style_playbook"): + write_checkpoint(tmp_path, "run", "research", "in_progress", {}) diff --git a/tests/lib/test_clip_embedder_compat.py b/tests/lib/test_clip_embedder_compat.py new file mode 100644 index 00000000..fc9e4741 --- /dev/null +++ b/tests/lib/test_clip_embedder_compat.py @@ -0,0 +1,26 @@ +from lib.clip_embedder import _as_feature_tensor + + +class _Tensor: + pass + + +class _ModelOutput: + def __init__(self, pooler_output): + self.pooler_output = pooler_output + self.last_hidden_state = object() + + +def test_transformers_4_tensor_passes_through() -> None: + tensor = _Tensor() + assert _as_feature_tensor(tensor) is tensor + + +def test_transformers_5_output_unwraps_projected_pooler_output() -> None: + tensor = _Tensor() + assert _as_feature_tensor(_ModelOutput(tensor)) is tensor + + +def test_missing_pooler_output_does_not_replace_features_with_none() -> None: + output = _ModelOutput(None) + assert _as_feature_tensor(output) is output diff --git a/tests/tools/test_audio_mixer_target_duration.py b/tests/tools/test_audio_mixer_target_duration.py new file mode 100644 index 00000000..58068450 --- /dev/null +++ b/tests/tools/test_audio_mixer_target_duration.py @@ -0,0 +1,94 @@ +import shutil +import subprocess +import math +import struct +import wave +from pathlib import Path + +import pytest + +from tools.audio.audio_mixer import AudioMixer + + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="ffmpeg and ffprobe are required", +) + + +def _tone(path: Path, frequency: int, duration: float) -> None: + subprocess.run( + [ + "ffmpeg", "-y", "-f", "lavfi", "-i", + f"sine=frequency={frequency}:duration={duration}", str(path), + ], + capture_output=True, + check=True, + timeout=30, + ) + + +def _duration(path: Path) -> float: + result = subprocess.run( + [ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(path), + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + return float(result.stdout.strip()) + + +def _tail_rms(path: Path, start_seconds: float, end_seconds: float) -> float: + with wave.open(str(path), "rb") as handle: + sample_width = handle.getsampwidth() + assert sample_width == 2 + frame_rate = handle.getframerate() + handle.setpos(int(start_seconds * frame_rate)) + raw = handle.readframes(int((end_seconds - start_seconds) * frame_rate)) + samples = struct.unpack(f"<{len(raw) // 2}h", raw) + return math.sqrt(sum(sample * sample for sample in samples) / len(samples)) + + +def test_full_mix_can_pin_the_composition_length(tmp_path) -> None: + speech = tmp_path / "speech.wav" + music = tmp_path / "music.wav" + output = tmp_path / "mix.wav" + _tone(speech, 440, 1) + _tone(music, 220, 3) + + result = AudioMixer().execute({ + "operation": "full_mix", + "tracks": [ + {"path": str(speech), "role": "speech"}, + {"path": str(music), "role": "music"}, + ], + "ducking": {"enabled": True}, + "normalize": False, + "target_duration": 3, + "output_path": str(output), + }) + + assert result.success, result.error + assert result.data["target_duration"] == 3 + assert _duration(output) == pytest.approx(3.0, abs=0.15) + assert _tail_rms(output, 2.0, 2.5) > 100 + + +@pytest.mark.parametrize("target", [0, -1, "not-a-number"]) +def test_full_mix_rejects_invalid_target_duration(tmp_path, target) -> None: + tone = tmp_path / "tone.wav" + _tone(tone, 440, 0.25) + + result = AudioMixer().execute({ + "operation": "full_mix", + "tracks": [{"path": str(tone), "role": "speech"}], + "target_duration": target, + "output_path": str(tmp_path / "mix.wav"), + }) + + assert result.success is False + assert "target_duration" in result.error diff --git a/tests/tools/test_bg_remove_api.py b/tests/tools/test_bg_remove_api.py new file mode 100644 index 00000000..a6d47a6b --- /dev/null +++ b/tests/tools/test_bg_remove_api.py @@ -0,0 +1,28 @@ +import sys +from unittest.mock import MagicMock + +from PIL import Image + + +def test_bg_remove_selects_model_through_rembg_session(monkeypatch, tmp_path) -> None: + fake_rembg = MagicMock() + fake_rembg.new_session.return_value = "selected-session" + fake_rembg.remove.side_effect = lambda image, **kwargs: image.convert("RGBA") + monkeypatch.setitem(sys.modules, "rembg", fake_rembg) + + input_path = tmp_path / "input.png" + Image.new("RGB", (8, 8), (10, 20, 30)).save(input_path) + + from tools.enhancement.bg_remove import BgRemove + + result = BgRemove().execute({ + "input_path": str(input_path), + "output_path": str(tmp_path / "output.png"), + "model": "isnet-general-use", + }) + + assert result.success, result.error + fake_rembg.new_session.assert_called_once_with("isnet-general-use") + kwargs = fake_rembg.remove.call_args.kwargs + assert kwargs["session"] == "selected-session" + assert "model_name" not in kwargs diff --git a/tests/tools/test_cinematic_remotion_adapter.py b/tests/tools/test_cinematic_remotion_adapter.py new file mode 100644 index 00000000..b3b02fa8 --- /dev/null +++ b/tests/tools/test_cinematic_remotion_adapter.py @@ -0,0 +1,177 @@ +import json +from pathlib import Path + +import pytest + +from tools.video.video_compose import VideoCompose + + +def test_cinematic_cut_adapter_builds_a_sequential_timeline() -> None: + scenes = VideoCompose._cuts_to_cinematic_scenes([ + { + "id": "v1", + "source": "clip.mp4", + "in_seconds": 2, + "out_seconds": 6, + "transition_in": "cut", + "transition_out": "none", + }, + { + "id": "title", + "source": "", + "type": "hero_title", + "text": "The signal arrives", + "in_seconds": 0, + "out_seconds": 3, + }, + ]) + + assert scenes[0] == { + "id": "v1", + "startSeconds": 0.0, + "durationSeconds": 4.0, + "kind": "video", + "src": "clip.mp4", + "trimBeforeSeconds": 2.0, + "trimAfterSeconds": 6.0, + "playbackRate": 1.0, + "fadeInFrames": 0, + "fadeOutFrames": 0, + } + assert scenes[1]["kind"] == "title" + assert scenes[1]["startSeconds"] == 4.0 + assert scenes[1]["text"] == "The signal arrives" + + +def test_cinematic_cut_adapter_preserves_playback_speed() -> None: + scenes = VideoCompose._cuts_to_cinematic_scenes([ + { + "id": "fast", + "source": "clip.mp4", + "in_seconds": 2, + "out_seconds": 6, + "speed": 2, + } + ]) + + assert scenes[0]["durationSeconds"] == 2 + assert scenes[0]["playbackRate"] == 2 + + +@pytest.mark.parametrize("uri_style", ["standard", "legacy_windows"]) +def test_remotion_media_staging_decodes_file_uris(tmp_path, uri_style) -> None: + source = tmp_path / "clip with space.mp4" + source.write_bytes(b"video") + public_dir = tmp_path / "public" + uri = source.as_uri() + if uri_style == "legacy_windows" and len(source.drive) == 2: + uri = f"file://{source.drive}{source.as_posix()[2:]}".replace(" ", "%20") + props = {"scenes": [{"src": uri}]} + + staged_count = VideoCompose._stage_remotion_media(props, public_dir) + + assert staged_count == 1 + assert props["scenes"][0]["src"] != uri + assert (public_dir / props["scenes"][0]["src"]).read_bytes() == b"video" + + +def test_remotion_render_adapts_cuts_and_stages_local_video(monkeypatch, tmp_path) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"not-a-real-video") + output = tmp_path / "render.mp4" + captured = {} + + def fake_run_command(self, command, **kwargs): + captured["command"] = command + captured["timeout"] = kwargs["timeout"] + props_arg = next(arg for arg in command if arg.startswith("--props=")) + captured["props"] = json.loads(Path(props_arg.split("=", 1)[1]).read_text()) + public_arg = next(arg for arg in command if arg.startswith("--public-dir=")) + public_dir = Path(public_arg.split("=", 1)[1]) + captured["staged_exists_during_render"] = ( + public_dir / captured["props"]["scenes"][0]["src"] + ).exists() + output.write_bytes(b"rendered") + + monkeypatch.setattr(VideoCompose, "run_command", fake_run_command) + + result = VideoCompose()._remotion_render({ + "edit_decisions": { + "renderer_family": "cinematic-trailer", + "cuts": [ + { + "id": "v1", + "source": str(source), + "in_seconds": 0, + "out_seconds": 2, + } + ], + }, + "output_path": str(output), + }) + + assert result.success, result.error + assert "cuts" not in captured["props"] + assert captured["props"]["scenes"][0]["kind"] == "video" + assert captured["staged_exists_during_render"] is True + assert result.data["staged_media_count"] == 1 + + +def test_remotion_timeout_scales_with_scene_count(monkeypatch, tmp_path) -> None: + output = tmp_path / "render.mp4" + captured = {} + + def fake_run_command(self, command, **kwargs): + captured["timeout"] = kwargs["timeout"] + output.write_bytes(b"rendered") + + monkeypatch.setattr(VideoCompose, "run_command", fake_run_command) + cuts = [ + { + "id": f"title-{index}", + "source": "", + "type": "hero_title", + "text": str(index), + "in_seconds": 0, + "out_seconds": 1, + } + for index in range(50) + ] + + result = VideoCompose()._remotion_render({ + "edit_decisions": {"renderer_family": "cinematic-trailer", "cuts": cuts}, + "output_path": str(output), + }) + + assert result.success, result.error + assert captured["timeout"] == 750 + + +def test_remotion_render_preserves_direct_cinematic_scenes(monkeypatch, tmp_path) -> None: + output = tmp_path / "render.mp4" + captured = {} + + def fake_run_command(self, command, **kwargs): + props_arg = next(arg for arg in command if arg.startswith("--props=")) + captured["props"] = json.loads(Path(props_arg.split("=", 1)[1]).read_text()) + output.write_bytes(b"rendered") + + monkeypatch.setattr(VideoCompose, "run_command", fake_run_command) + scene = { + "id": "authored", + "kind": "title", + "text": "Keep me", + "startSeconds": 0, + "durationSeconds": 1, + } + + result = VideoCompose()._remotion_render({ + "composition_data": { + "renderer_family": "cinematic-trailer", + "scenes": [scene], + }, + "output_path": str(output), + }) + + assert result.success, result.error + assert captured["props"]["scenes"] == [scene] diff --git a/tests/tools/test_corpus_builder_total_failure.py b/tests/tools/test_corpus_builder_total_failure.py new file mode 100644 index 00000000..c0180380 --- /dev/null +++ b/tests/tools/test_corpus_builder_total_failure.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +import pytest + +import tools.video.stock_sources as stock_sources +from tools.video.corpus_builder import CorpusBuilder + + +@dataclass +class _Candidate: + clip_id: str + + +class _Source: + name = "fake" + + def __init__(self, count: int) -> None: + self.count = count + + def is_available(self) -> bool: + return True + + def search(self, query, filters): + return [_Candidate(f"clip-{index}") for index in range(self.count)] + + +@pytest.fixture +def run_builder(monkeypatch, tmp_path): + def run(count: int, processor): + monkeypatch.setattr(stock_sources, "available_sources", lambda: [_Source(count)]) + monkeypatch.setattr(stock_sources, "source_summary", lambda: {}) + monkeypatch.setattr(CorpusBuilder, "_process_candidate", processor) + return CorpusBuilder().execute({ + "corpus_dir": str(tmp_path / f"corpus-{count}"), + "queries": [{"query": "city at night"}], + "max_new_clips": 50, + }) + + return run + + +def test_all_candidate_failures_fail_closed_with_diagnostics(run_builder) -> None: + def broken_clip_stack(*args, **kwargs): + raise AttributeError("BaseModelOutput has no attribute norm") + + result = run_builder(4, broken_clip_stack) + + assert result.success is False + assert result.data["candidates_seen"] == 4 + assert result.data["clips_failed"] == 4 + assert "corpus index is empty" in result.error + assert "BaseModelOutput" in result.error + + +def test_no_candidates_is_a_valid_empty_search(run_builder) -> None: + result = run_builder(0, lambda *args, **kwargs: None) + + assert result.success is True + assert result.data["candidates_seen"] == 0 diff --git a/tests/tools/test_google_vertex_backends.py b/tests/tools/test_google_vertex_backends.py new file mode 100644 index 00000000..375ba14f --- /dev/null +++ b/tests/tools/test_google_vertex_backends.py @@ -0,0 +1,97 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_blank_google_location_uses_documented_default(monkeypatch) -> None: + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "") + from tools.google_credentials import resolve_google_location + + assert resolve_google_location() == "us-central1" + + +def test_google_music_requests_the_global_vertex_location(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("GOOGLE_API_KEY", "test-key") + import tools.google_credentials as credentials + + captured = {} + + def stop_before_network(http_options=None, location=None): + captured["location"] = location + raise RuntimeError("stop before network") + + monkeypatch.setattr(credentials, "get_genai_client", stop_before_network) + + from tools.audio.google_music import GoogleMusic + + result = GoogleMusic().execute({ + "prompt": "solo piano", + "output_path": str(tmp_path / "music.mp3"), + }) + + assert result.success is False + assert captured["location"] == "global" + + +class _VideoAsset: + uri = None + + def __init__(self, video_bytes): + self.video_bytes = video_bytes + + def save(self, path): + Path(path).write_bytes(self.video_bytes or b"") + + +class _Models: + def __init__(self, video_bytes): + self.video_bytes = video_bytes + + def generate_videos(self, **kwargs): + asset = _VideoAsset(self.video_bytes) + generated = SimpleNamespace(video=asset) + response = SimpleNamespace(generated_videos=[generated]) + return SimpleNamespace(done=True, error=None, response=response) + + +class _VertexClient: + vertexai = True + + def __init__(self, video_bytes): + self.models = _Models(video_bytes) + self.files = SimpleNamespace( + download=lambda **kwargs: pytest.fail("Vertex must not call files.download") + ) + + +@pytest.mark.parametrize( + ("video_bytes", "expected_success"), + [(b"VIDEO_BYTES", True), (None, False)], +) +def test_veo_accepts_vertex_and_requires_inline_bytes( + monkeypatch, tmp_path, video_bytes, expected_success +) -> None: + monkeypatch.setenv("GOOGLE_API_KEY", "test-key") + import tools.google_credentials as credentials + + monkeypatch.setattr( + credentials, + "get_genai_client", + lambda http_options=None, location=None: _VertexClient(video_bytes), + ) + + from tools.video.veo_video import VeoVideo + + output = tmp_path / "video.mp4" + result = VeoVideo().execute({ + "backend": "google", + "prompt": "wind moving across grassland", + "output_path": str(output), + }) + + assert result.success is expected_success + if expected_success: + assert output.read_bytes() == b"VIDEO_BYTES" + else: + assert "without inline bytes" in result.error diff --git a/tests/tools/test_hyperframes_compose.py b/tests/tools/test_hyperframes_compose.py index 6438a4fa..d3780fcf 100644 --- a/tests/tools/test_hyperframes_compose.py +++ b/tests/tools/test_hyperframes_compose.py @@ -250,6 +250,11 @@ def test_runtime_check_succeeds_when_npm_resolves(monkeypatch): "_resolve_npm_package", classmethod(lambda cls: {"version": "0.4.5"}), ) + monkeypatch.setattr( + HyperFramesCompose, + "_probe_cli", + classmethod(lambda cls: {"status": "ok"}), + ) rc = HyperFramesCompose()._runtime_check() # Local binaries must still pass for this to go green. if rc["node_major"] is None or not rc["ffmpeg_available"] or not rc["npx_available"]: @@ -259,6 +264,31 @@ def test_runtime_check_succeeds_when_npm_resolves(monkeypatch): assert rc["reasons"] == [] +def test_runtime_check_fails_when_published_cli_crashes(monkeypatch): + monkeypatch.setattr( + HyperFramesCompose, + "_resolve_npm_package", + classmethod(lambda cls: {"version": "0.7.89"}), + ) + monkeypatch.setattr( + HyperFramesCompose, + "_probe_cli", + classmethod( + lambda cls: { + "error": 'doctor failed: The "file" argument must be of type string' + } + ), + ) + + rc = HyperFramesCompose()._runtime_check() + + if rc["node_major"] is None or not rc["ffmpeg_available"] or not rc["npx_available"]: + pytest.skip("Local runtime floor not met on this machine") + assert rc["runtime_available"] is False + assert rc["cli_probe_error"] is not None + assert any("not executable" in reason for reason in rc["reasons"]) + + def test_video_compose_render_engines_follow_hyperframes_runtime_check(monkeypatch): """Regression: `video_compose.get_info()['render_engines']['hyperframes']` must track the true availability, not just the local-binary floor. diff --git a/tests/tools/test_mps_device.py b/tests/tools/test_mps_device.py index 844d633e..b3f5fdbd 100644 --- a/tests/tools/test_mps_device.py +++ b/tests/tools/test_mps_device.py @@ -213,8 +213,9 @@ def test_upscale_build_upsampler_uses_signature_guard(monkeypatch): # Build a fake RealESRGANer whose __init__ DOES accept device= class FakeRealESRGANer: - def __init__(self, *, scale, model_path, model, dni_weight, half, device=None): + def __init__(self, *, scale, model_path, model, dni_weight, half, tile=0, tile_pad=10, device=None): self.called_with_device = device + self.called_with_tile = tile fake_realesrganer_cls = FakeRealESRGANer monkeypatch.setitem(sys.modules, "torch", fake_torch) @@ -239,6 +240,7 @@ def test_upscale_build_upsampler_uses_signature_guard(monkeypatch): tool = upscale.Upscale() result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False) assert result.called_with_device == "device(mps)" + assert result.called_with_tile == 256 def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch): @@ -252,8 +254,9 @@ def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch): # Build a fake RealESRGANer whose __init__ does NOT accept device= class FakeRealESRGANerNoDevice: - def __init__(self, *, scale, model_path, model, dni_weight, half): + def __init__(self, *, scale, model_path, model, dni_weight, half, tile=0, tile_pad=10): self.called_with_device = None # no device param + self.called_with_tile = tile fake_realesrganer_cls = FakeRealESRGANerNoDevice monkeypatch.setitem(sys.modules, "torch", fake_torch) @@ -279,6 +282,7 @@ def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch): # Should NOT raise TypeError about unexpected keyword argument 'device' result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False) assert result.called_with_device is None + assert result.called_with_tile == 256 # ------------------------------------------------------------------ diff --git a/tests/tools/test_remotion_audio_mux.py b/tests/tools/test_remotion_audio_mux.py new file mode 100644 index 00000000..210129b8 --- /dev/null +++ b/tests/tools/test_remotion_audio_mux.py @@ -0,0 +1,59 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tools.video.video_compose import VideoCompose + + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="ffmpeg and ffprobe are required", +) + + +def _run(command: list[str]) -> None: + subprocess.run(command, capture_output=True, check=True, timeout=30) + + +def test_external_audio_mux_adds_audible_stream_without_changing_video_length(tmp_path) -> None: + video = tmp_path / "video.mp4" + audio = tmp_path / "audio.wav" + _run([ + "ffmpeg", "-y", "-f", "lavfi", "-i", + "color=c=red:s=320x180:d=2:r=30", + "-c:v", "libx264", "-pix_fmt", "yuv420p", str(video), + ]) + _run([ + "ffmpeg", "-y", "-f", "lavfi", "-i", + "sine=frequency=440:duration=1", str(audio), + ]) + + result = VideoCompose()._mux_external_audio(video, audio) + + assert result.success, result.error + streams = subprocess.run( + [ + "ffprobe", "-v", "error", "-show_entries", + "stream=codec_type", "-of", "csv=p=0", str(video), + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ).stdout.splitlines() + duration = float(subprocess.run( + [ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", str(video), + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ).stdout.strip()) + + assert "video" in streams + assert "audio" in streams + assert duration == pytest.approx(2.0, abs=0.15) diff --git a/tests/tools/test_transcriber_device_selection.py b/tests/tools/test_transcriber_device_selection.py new file mode 100644 index 00000000..11d50012 --- /dev/null +++ b/tests/tools/test_transcriber_device_selection.py @@ -0,0 +1,83 @@ +import sys +from types import SimpleNamespace + +from tools.analysis.transcriber import Transcriber + + +class _Info: + language = "en" + duration = 1.0 + + +def test_transcriber_uses_ctranslate2_cuda_without_torch(monkeypatch, tmp_path) -> None: + devices = [] + + class FakeWhisperModel: + def __init__(self, model_size, *, device, compute_type): + devices.append((device, compute_type)) + + def transcribe(self, *args, **kwargs): + return iter(()), _Info() + + monkeypatch.setitem( + sys.modules, + "faster_whisper", + SimpleNamespace(WhisperModel=FakeWhisperModel), + ) + monkeypatch.setitem( + sys.modules, + "ctranslate2", + SimpleNamespace( + get_cuda_device_count=lambda: 1, + get_supported_compute_types=lambda device: {"float16", "float32"}, + ), + ) + input_path = tmp_path / "audio.wav" + input_path.write_bytes(b"fake") + + result = Transcriber().execute({"input_path": str(input_path), "output_dir": str(tmp_path)}) + + assert result.success, result.error + assert devices == [("cuda", "float16")] + assert result.data["device"] == "cuda" + + +def test_transcriber_falls_back_when_cuda_fails_during_iteration(monkeypatch, tmp_path) -> None: + devices = [] + + class FakeWhisperModel: + def __init__(self, model_size, *, device, compute_type): + self.device = device + devices.append((device, compute_type)) + + def transcribe(self, *args, **kwargs): + if self.device == "cuda": + def broken_iterator(): + raise RuntimeError("cublas64_12.dll not found") + yield + + return broken_iterator(), _Info() + return iter(()), _Info() + + monkeypatch.setitem( + sys.modules, + "faster_whisper", + SimpleNamespace(WhisperModel=FakeWhisperModel), + ) + monkeypatch.setitem( + sys.modules, + "ctranslate2", + SimpleNamespace( + get_cuda_device_count=lambda: 1, + get_supported_compute_types=lambda device: {"float16"}, + ), + ) + input_path = tmp_path / "audio.wav" + input_path.write_bytes(b"fake") + + result = Transcriber().execute({"input_path": str(input_path), "output_dir": str(tmp_path)}) + + assert result.success, result.error + assert devices == [("cuda", "float16"), ("cpu", "int8")] + assert result.data["device"] == "cpu" + assert "cublas64_12.dll" in result.data["gpu_fallback_reason"] diff --git a/tools/analysis/transcriber.py b/tools/analysis/transcriber.py index bb309fc5..8e384152 100644 --- a/tools/analysis/transcriber.py +++ b/tools/analysis/transcriber.py @@ -135,50 +135,76 @@ class Transcriber(BaseTool): start = time.time() - # Load model (CPU by default, CUDA if available) + # faster-whisper executes through CTranslate2, so that runtime—not + # PyTorch—is authoritative for CUDA availability and compute types. + device = "cpu" + compute_type = "int8" try: - import torch - device = "cuda" if torch.cuda.is_available() else "cpu" - compute_type = "float16" if device == "cuda" else "int8" - except ImportError: + import ctranslate2 + + if ctranslate2.get_cuda_device_count() > 0: + supported = ctranslate2.get_supported_compute_types("cuda") + for candidate in ("float16", "int8_float16", "float32"): + if candidate in supported: + device = "cuda" + compute_type = candidate + break + except Exception: + # Probing is advisory. CPU remains a safe deterministic baseline. + pass + + def _transcribe_on(selected_device: str, selected_compute_type: str): + model = WhisperModel( + model_size, + device=selected_device, + compute_type=selected_compute_type, + ) + segments_iter, transcription_info = model.transcribe( + str(input_path), + language=language, + word_timestamps=True, + vad_filter=True, + ) + + parsed_segments = [] + parsed_words = [] + # faster-whisper evaluates lazily. Draining the iterator here keeps + # missing CUDA runtime libraries inside the fallback boundary. + for seg in segments_iter: + seg_data = { + "id": seg.id, + "start": round(seg.start, 3), + "end": round(seg.end, 3), + "text": seg.text.strip(), + } + + if seg.words: + words = [] + for word in seg.words: + word_entry = { + "word": word.word, + "start": round(word.start, 3), + "end": round(word.end, 3), + "probability": round(word.probability, 3), + } + words.append(word_entry) + parsed_words.append(word_entry) + seg_data["words"] = words + + parsed_segments.append(seg_data) + + return parsed_segments, parsed_words, transcription_info + + gpu_fallback_reason = None + try: + segments, word_timestamps, info = _transcribe_on(device, compute_type) + except Exception as exc: + if device == "cpu": + raise + gpu_fallback_reason = f"{type(exc).__name__}: {exc}" device = "cpu" compute_type = "int8" - - model = WhisperModel(model_size, device=device, compute_type=compute_type) - - # Transcribe - segments_iter, info = model.transcribe( - str(input_path), - language=language, - word_timestamps=True, - vad_filter=True, - ) - - segments = [] - word_timestamps = [] - - for seg in segments_iter: - seg_data = { - "id": seg.id, - "start": round(seg.start, 3), - "end": round(seg.end, 3), - "text": seg.text.strip(), - } - - if seg.words: - words = [] - for w in seg.words: - word_entry = { - "word": w.word, - "start": round(w.start, 3), - "end": round(w.end, 3), - "probability": round(w.probability, 3), - } - words.append(word_entry) - word_timestamps.append(word_entry) - seg_data["words"] = words - - segments.append(seg_data) + segments, word_timestamps, info = _transcribe_on(device, compute_type) detected_language = language or info.language duration = info.duration @@ -198,6 +224,8 @@ class Transcriber(BaseTool): "duration_seconds": round(duration, 3), "model_size": model_size, "device": device, + "compute_type": compute_type, + "gpu_fallback_reason": gpu_fallback_reason, } # Write transcript JSON diff --git a/tools/audio/audio_mixer.py b/tools/audio/audio_mixer.py index 189a153a..48dfcb6a 100644 --- a/tools/audio/audio_mixer.py +++ b/tools/audio/audio_mixer.py @@ -184,6 +184,14 @@ class AudioMixer(BaseTool): "default": 0.5, "description": "Duration of fade in/out at segment boundaries (seconds).", }, + "target_duration": { + "type": "number", + "exclusiveMinimum": 0, + "description": ( + "full_mix only. Exact output length in seconds. Pads a short " + "mix and trims a long mix so audio matches the composition." + ), + }, }, } @@ -500,6 +508,15 @@ class AudioMixer(BaseTool): output_path.parent.mkdir(parents=True, exist_ok=True) normalize = inputs.get("normalize", True) ducking = inputs.get("ducking", {"enabled": True}) + target_duration = inputs.get("target_duration") + target: float | None = None + if target_duration is not None: + try: + target = float(target_duration) + except (TypeError, ValueError): + return ToolResult(success=False, error="target_duration must be a positive number") + if target <= 0: + return ToolResult(success=False, error="target_duration must be greater than zero") speech_tracks = [t for t in tracks if t.get("role") in ("speech", "primary")] music_tracks = [t for t in tracks if t.get("role") in ("music", "secondary")] @@ -547,7 +564,14 @@ class AudioMixer(BaseTool): ) else: filter_parts.append(f"[a{speech_indices[0]}]acopy[speech_all]") - filter_parts.append("[speech_all]asplit=2[speech_key][speech_out]") + if target is not None: + filter_parts.append("[speech_all]asplit=2[speech_key_raw][speech_out]") + filter_parts.append( + f"[speech_key_raw]apad=whole_dur={target}," + f"atrim=duration={target},asetpts=PTS-STARTPTS[speech_key]" + ) + else: + filter_parts.append("[speech_all]asplit=2[speech_key][speech_out]") # Mix music tracks together music_start = len(speech_tracks) @@ -596,19 +620,34 @@ class AudioMixer(BaseTool): f"{all_labels}amix=inputs={len(all_tracks)}:duration=longest:dropout_transition=2[premix]" ) + # A ducked music stream is gated by the speech sidechain, so its tail + # can disappear when narration ends. If the caller knows the video + # duration, make that the authoritative mix length before loudness + # normalization: apad extends short audio and atrim caps long audio. + premix_label = "premix" + if target is not None: + filter_parts.append( + f"[premix]apad=whole_dur={target},atrim=duration={target}," + "asetpts=PTS-STARTPTS[premix_duration]" + ) + premix_label = "premix_duration" + # Normalize if normalize: - filter_parts.append(self._loudnorm_filter(inputs, "premix", "out")) + filter_parts.append(self._loudnorm_filter(inputs, premix_label, "out")) out_label = "[out]" else: - out_label = "[premix]" + out_label = f"[{premix_label}]" filter_complex = ";".join(p for p in filter_parts if p) cmd = ["ffmpeg", "-y"] cmd.extend(input_args) cmd.extend(["-filter_complex", filter_complex]) - cmd.extend(["-map", out_label, str(output_path)]) + cmd.extend(["-map", out_label]) + if target is not None: + cmd.extend(["-t", str(target)]) + cmd.append(str(output_path)) self.run_command(cmd) @@ -621,6 +660,7 @@ class AudioMixer(BaseTool): "sfx_tracks": len(sfx_tracks), "ducking_enabled": duck_enabled, "normalized": normalize, + "target_duration": target_duration, "output": str(output_path), }, artifacts=[str(output_path)], diff --git a/tools/audio/google_music.py b/tools/audio/google_music.py index 891550c1..67f25d87 100644 --- a/tools/audio/google_music.py +++ b/tools/audio/google_music.py @@ -146,7 +146,8 @@ class GoogleMusic(BaseTool): from tools.google_credentials import get_genai_client, GOOGLE_API_TIMEOUT_MS http_options = types.HttpOptions(timeout=GOOGLE_API_TIMEOUT_MS) - client = get_genai_client(http_options=http_options) + # Lyria 3 is served only from Vertex's global location. + client = get_genai_client(http_options=http_options, location="global") except ImportError as e: return ToolResult( success=False, diff --git a/tools/enhancement/bg_remove.py b/tools/enhancement/bg_remove.py index 7802f45a..7d1c40e8 100644 --- a/tools/enhancement/bg_remove.py +++ b/tools/enhancement/bg_remove.py @@ -130,7 +130,7 @@ class BgRemove(BaseTool): result_image = rembg.remove( input_image, - model_name=model_name, + session=rembg.new_session(model_name), alpha_matting=alpha_matting, ) diff --git a/tools/enhancement/upscale.py b/tools/enhancement/upscale.py index 03648405..1fa18ccd 100644 --- a/tools/enhancement/upscale.py +++ b/tools/enhancement/upscale.py @@ -297,6 +297,11 @@ class Upscale(BaseTool): "model": model, "dni_weight": denoise_strength, "half": half, + # Full-frame x4 inference can terminate the process on low-memory + # CPU/MPS hosts before Python can raise an exception. Bound the + # working set there; keep CUDA on the faster single-pass path. + "tile": 0 if _device == "cuda" else 256, + "tile_pad": 10, } # Guard: only pass device= if the installed version accepts it if "device" in inspect.signature(RealESRGANer.__init__).parameters: diff --git a/tools/google_credentials.py b/tools/google_credentials.py index 71158c73..77565c2e 100644 --- a/tools/google_credentials.py +++ b/tools/google_credentials.py @@ -18,6 +18,12 @@ from typing import Any # Broad scope that covers Cloud Text-to-Speech and Vertex AI prediction. CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform" + +def resolve_google_location(location: str | None = None) -> str: + """Return a Vertex location, treating blank env values as unset.""" + + return location or os.environ.get("GOOGLE_CLOUD_LOCATION") or "us-central1" + # Shared constants for long-running Google/Vertex AI generation calls (e.g. music, video) GOOGLE_API_TIMEOUT_SECONDS = 600 GOOGLE_API_TIMEOUT_MS = GOOGLE_API_TIMEOUT_SECONDS * 1000 @@ -38,8 +44,15 @@ def has_google_credentials() -> bool: ) -def get_genai_client(http_options: Any | None = None) -> Any: - """Lazily import and initialize the Google GenAI Client based on configured credentials.""" +def get_genai_client( + http_options: Any | None = None, + location: str | None = None, +) -> Any: + """Initialize Google GenAI using the configured credential mode. + + ``location`` overrides the Vertex region for globally hosted models. It is + deliberately ignored by the API-key backend, which has no region setting. + """ from google import genai api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") @@ -51,7 +64,7 @@ def get_genai_client(http_options: Any | None = None) -> Any: if use_vertex or (not api_key and service_account_configured()): kwargs = { "vertexai": True, - "location": os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1"), + "location": resolve_google_location(location), "http_options": http_options, } project_id = resolve_project_id() diff --git a/tools/graphics/google_imagen.py b/tools/graphics/google_imagen.py index ac2717ba..2850a8e3 100644 --- a/tools/graphics/google_imagen.py +++ b/tools/graphics/google_imagen.py @@ -22,6 +22,7 @@ from tools.base_tool import ( ) from tools.google_credentials import ( get_access_token, + resolve_google_location, resolve_project_id, service_account_configured, has_google_credentials, @@ -241,7 +242,7 @@ class GoogleImagen(BaseTool): } if bearer_token: - location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") + location = resolve_google_location() url = ( f"https://{location}-aiplatform.googleapis.com/v1/projects/" f"{project_id}/locations/{location}/publishers/google/models/" diff --git a/tools/video/corpus_builder.py b/tools/video/corpus_builder.py index f426b40c..cb52ff6a 100644 --- a/tools/video/corpus_builder.py +++ b/tools/video/corpus_builder.py @@ -392,6 +392,38 @@ class CorpusBuilder(BaseTool): except Exception as e: cache_snapshot = {"error": f"{type(e).__name__}: {e}"} + # Per-candidate tolerance is useful only while at least one item + # survives. If every discovered candidate fails, reporting success + # persists an empty index and hides a systemic codec/CLIP failure + # until retrieval. A no-result or skip-only run remains valid. + total_failure = bool(candidates_seen) and not added_ids and not skipped + if total_failure: + first_errors = "; ".join( + item["error"] + for item in errors + if item.get("phase") == "process" + )[:400] + return ToolResult( + success=False, + error=( + f"All {failed} of {candidates_seen} candidates failed to " + "process; corpus index is empty. Check the media decoder " + "and the CLIP `transformers`/`torch` compatibility. " + f"First errors: {first_errors or '(none recorded)'}" + ), + data={ + "corpus_dir": str(corpus_dir), + "queries_run": len(queries), + "candidates_seen": candidates_seen, + "clips_added": 0, + "clips_skipped_existing": skipped, + "clips_failed": failed, + "total_corpus_size": len(corp), + "errors": errors[:25], + }, + duration_seconds=round(elapsed, 2), + ) + return ToolResult( success=True, data={ diff --git a/tools/video/hyperframes_compose.py b/tools/video/hyperframes_compose.py index e6c798c8..7511b686 100644 --- a/tools/video/hyperframes_compose.py +++ b/tools/video/hyperframes_compose.py @@ -226,6 +226,7 @@ class HyperFramesCompose(BaseTool): # We cache per-process so the first call pays ~2-5s and subsequent calls # (get_info spam from the registry) are free. _npm_resolve_cache: Optional[dict[str, str]] = None + _cli_probe_cache: Optional[dict[str, str]] = None @classmethod def _node_major_version(cls) -> Optional[int]: @@ -301,6 +302,45 @@ class HyperFramesCompose(BaseTool): cls._npm_resolve_cache = {"version": version} return cls._npm_resolve_cache + @classmethod + def _probe_cli(cls) -> dict[str, str]: + """Run the published CLI's doctor command once per process. + + Package resolution alone does not prove that the executable can start: + an upstream packaging regression can publish successfully while every + CLI command crashes during bootstrap. Provider preflight must not call + that state available. + """ + if cls._cli_probe_cache is not None: + return cls._cli_probe_cache + + npx = shutil.which("npx") + if not npx: + cls._cli_probe_cache = {"error": "npx not on PATH"} + return cls._cli_probe_cache + + try: + proc = subprocess.run( + [npx, "--yes", cls._NPM_PACKAGE, "doctor", "--json"], + capture_output=True, + text=True, + timeout=20, + ) + except subprocess.TimeoutExpired: + cls._cli_probe_cache = {"error": "doctor timed out after 20s"} + return cls._cli_probe_cache + except (OSError, subprocess.SubprocessError) as exc: + cls._cli_probe_cache = {"error": f"doctor failed: {type(exc).__name__}"} + return cls._cli_probe_cache + + if proc.returncode != 0: + output = "\n".join(filter(None, [proc.stderr, proc.stdout])).strip() + tail = output.splitlines()[-1][:200] if output else f"exit {proc.returncode}" + cls._cli_probe_cache = {"error": f"doctor failed: {tail}"} + else: + cls._cli_probe_cache = {"status": "ok"} + return cls._cli_probe_cache + def _runtime_check(self) -> dict[str, Any]: """Return availability state for the HyperFrames runtime. @@ -336,6 +376,12 @@ class HyperFramesCompose(BaseTool): f"{npm_resolve['error']}" ) + cli_probe: dict[str, str] = {} + if not reasons: + cli_probe = self._probe_cli() + if "error" in cli_probe: + reasons.append(f"published CLI is not executable: {cli_probe['error']}") + return { "runtime_available": not reasons, "node_major": node_major, @@ -344,6 +390,8 @@ class HyperFramesCompose(BaseTool): "npm_package": self._NPM_PACKAGE, "npm_package_version": npm_resolve.get("version"), "npm_resolve_error": npm_resolve.get("error"), + "cli_probe_status": cli_probe.get("status"), + "cli_probe_error": cli_probe.get("error"), "reasons": reasons, } diff --git a/tools/video/veo_video.py b/tools/video/veo_video.py index de4efea2..11606d7b 100644 --- a/tools/video/veo_video.py +++ b/tools/video/veo_video.py @@ -308,13 +308,6 @@ class VeoVideo(BaseTool): client._api_client, "vertexai", False ) - if is_vertex: - return ToolResult( - success=False, - error="Google Veo video generation via google-genai is only supported using the Gemini Developer API (API key) backend. " - "Please configure GEMINI_API_KEY/GOOGLE_API_KEY or use the FAL.ai backend.", - ) - prompt = inputs["prompt"] operation = inputs.get("operation", "text_to_video") model_variant = inputs.get("model_variant", "veo3.1") @@ -501,7 +494,19 @@ class VeoVideo(BaseTool): success=False, error="No video asset returned in the response.", ) - client.files.download(file=video_asset) + if not is_vertex: + # The Files service is a Gemini Developer API feature. Vertex + # returns bytes inline when no GCS output URI is requested. + client.files.download(file=video_asset) + elif not getattr(video_asset, "video_bytes", None): + return ToolResult( + success=False, + error=( + "Vertex AI returned a video without inline bytes " + f"(uri={getattr(video_asset, 'uri', None)!r}). Configure " + "the request without an output GCS URI so bytes are returned inline." + ), + ) output_path = Path(inputs.get("output_path", "veo_output.mp4")) output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index 976a2e55..6fd69ef4 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -31,11 +31,14 @@ the agent to re-ask the user rather than substituting a different engine. from __future__ import annotations import json +import hashlib import logging +import shutil import subprocess import time from pathlib import Path from typing import Any, Optional +from urllib.parse import unquote, urlsplit from tools.base_tool import ( BaseTool, @@ -387,6 +390,49 @@ class VideoCompose(BaseTool): except Exception: return False + def _mux_external_audio(self, video_path: Path, audio_path: str | Path) -> ToolResult: + """Atomically replace a rendered video's audio with the approved mix.""" + + audio = Path(audio_path).resolve() + if not audio.is_file(): + return ToolResult(success=False, error=f"Mixed audio not found: {audio}") + + temp_output = video_path.with_name( + f".{video_path.stem}.audio-mux-{time.time_ns()}{video_path.suffix}" + ) + try: + self.run_command([ + "ffmpeg", "-y", + "-i", str(video_path), + "-i", str(audio), + "-map", "0:v:0", + "-map", "1:a:0", + "-c:v", "copy", + "-c:a", "aac", + "-b:a", "192k", + "-af", "apad", + "-shortest", + "-movflags", "+faststart", + str(temp_output), + ]) + if not temp_output.is_file(): + return ToolResult( + success=False, + error=f"Audio mux completed but output file is missing: {temp_output}", + ) + temp_output.replace(video_path) + except Exception as exc: + return ToolResult(success=False, error=f"Could not mux mixed audio: {exc}") + finally: + if temp_output.exists(): + temp_output.unlink() + + return ToolResult( + success=True, + data={"output": str(video_path), "has_mixed_audio": True}, + artifacts=[str(video_path)], + ) + def _compose(self, inputs: dict[str, Any]) -> ToolResult: """FFmpeg composition: concat video cuts, add audio, burn subtitles. @@ -714,6 +760,124 @@ class VideoCompose(BaseTool): ) return comp + @staticmethod + def _cuts_to_cinematic_scenes(cuts: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Adapt canonical sequential cuts to CinematicRenderer's scene contract.""" + + scenes: list[dict[str, Any]] = [] + timeline_cursor = 0.0 + hard_transitions = {"cut", "none"} + title_types = {"hero_title", "text_card", "title"} + + for index, cut in enumerate(cuts): + try: + source_in = float(cut.get("in_seconds", 0)) + source_out = float(cut.get("out_seconds", source_in)) + speed = max(float(cut.get("speed", 1.0)), 0.1) + except (TypeError, ValueError): + continue + duration = max(0.0, (source_out - source_in) / speed) + if duration <= 0: + continue + + scene_id = str(cut.get("id") or f"cut-{index + 1}") + source = str(cut.get("source") or "") + cut_type = str(cut.get("type") or "").lower() + common = { + "id": scene_id, + "startSeconds": timeline_cursor, + "durationSeconds": duration, + } + + if cut_type in title_types or not source: + scene: dict[str, Any] = { + **common, + "kind": "title", + "text": str( + cut.get("text") + or cut.get("title") + or cut.get("reason") + or scene_id + ), + } + if source: + scene["backgroundSrc"] = source + scene["backgroundTrimBeforeSeconds"] = source_in + scene["backgroundTrimAfterSeconds"] = source_out + else: + scene = { + **common, + "kind": "video", + "src": source, + "trimBeforeSeconds": source_in, + "trimAfterSeconds": source_out, + "playbackRate": speed, + } + if str(cut.get("transition_in") or "").lower() in hard_transitions: + scene["fadeInFrames"] = 0 + if str(cut.get("transition_out") or "").lower() in hard_transitions: + scene["fadeOutFrames"] = 0 + + scenes.append(scene) + timeline_cursor += duration + + return scenes + + @staticmethod + def _stage_remotion_media(value: Any, public_dir: Path) -> int: + """Copy local media references into a Remotion public dir in-place. + + OffthreadVideo's compositor rejects ``file://`` sources. Rewriting + staged files to relative ``staticFile()`` paths works for video and + image components on every platform. + """ + + staged_by_source: dict[Path, str] = {} + media_keys = {"source", "src", "backgroundSrc"} + + def visit(node: Any, parent_key: str | None = None) -> Any: + if isinstance(node, dict): + for key, child in list(node.items()): + node[key] = visit(child, key) + return node + if isinstance(node, list): + for index, child in enumerate(node): + node[index] = visit(child, parent_key) + return node + if not isinstance(node, str) or parent_key not in media_keys: + return node + if node.startswith(("http://", "https://", "data:")): + return node + + if node.lower().startswith("file://"): + parsed = urlsplit(node) + decoded_path = unquote(parsed.path) + if len(parsed.netloc) == 2 and parsed.netloc[1] == ":": + raw_path = f"{parsed.netloc}{decoded_path}" + elif parsed.netloc and parsed.netloc.lower() != "localhost": + raw_path = f"//{parsed.netloc}{decoded_path}" + else: + raw_path = decoded_path + # Standard Windows file URIs use file:///C:/...; pathlib on + # Windows needs the drive path without the URI's leading slash. + if len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] + else: + raw_path = node + source = Path(raw_path).resolve() + if not source.is_file(): + return node + if source not in staged_by_source: + digest = hashlib.sha256(str(source).encode("utf-8")).hexdigest()[:12] + name = f"{digest}-{source.name}" + public_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, public_dir / name) + staged_by_source[source] = name + return staged_by_source[source] + + visit(value) + return len(staged_by_source) + def _render_via_atelier( self, inputs: dict[str, Any], @@ -840,6 +1004,11 @@ class VideoCompose(BaseTool): error=f"Atelier render completed but output file missing: {output_path}", ) + if inputs.get("audio_path"): + mux_result = self._mux_external_audio(output_path, inputs["audio_path"]) + if not mux_result.success: + return mux_result + # --- Atelier post-render review ------------------------------------- # The cut-schema paths run _run_final_review (technical/visual/audio # probes + transcript-vs-script). Atelier MUST do the same so hero @@ -1063,8 +1232,12 @@ class VideoCompose(BaseTool): try: from styles.playbook_loader import load_playbook playbook = load_playbook(playbook_name) - except Exception: - pass + except Exception as exc: + logging.getLogger(__name__).warning( + "Could not load style playbook %r for Remotion theme: %s", + playbook_name, + exc, + ) if playbook: vl = playbook.get("visual_language", {}) @@ -1418,6 +1591,8 @@ class VideoCompose(BaseTool): # would only take effect on a direct _remotion_render() call. if inputs.get("remotion_timeout_ms") is not None: remotion_inputs["remotion_timeout_ms"] = inputs["remotion_timeout_ms"] + if inputs.get("public_dir") is not None: + remotion_inputs["public_dir"] = inputs["public_dir"] render_result = self._remotion_render(remotion_inputs) # Governance: NEVER silently fall back to FFmpeg when Remotion fails. @@ -1437,6 +1612,11 @@ class VideoCompose(BaseTool): f"Per governance: renderer downgrade requires user approval." ), ) + if inputs.get("audio_path"): + mux_result = self._mux_external_audio(output_path, inputs["audio_path"]) + if not mux_result.success: + return mux_result + render_result.data["has_mixed_audio"] = True else: # --- FFmpeg fallback: only when Remotion is unavailable --- options = inputs.get("options", {}) @@ -1546,7 +1726,12 @@ class VideoCompose(BaseTool): try: from styles.playbook_loader import load_playbook # type: ignore playbook_data = load_playbook(playbook_name) - except Exception: + except Exception as exc: + logging.getLogger(__name__).warning( + "Could not load style playbook %r for HyperFrames bridge: %s", + playbook_name, + exc, + ) playbook_data = None hf_inputs: dict[str, Any] = { @@ -1677,8 +1862,6 @@ class VideoCompose(BaseTool): types, and transitions using React-based frame-accurate rendering. Accepts edit_decisions (with resolved file paths) or raw composition_data. """ - import shutil - if not shutil.which("npx"): return ToolResult( success=False, @@ -1700,16 +1883,6 @@ class VideoCompose(BaseTool): # Deep-copy props so we don't mutate the original props = json.loads(json.dumps(composition_data)) - # Convert absolute file paths to file:// URIs for Remotion's - # Img and OffthreadVideo components - for cut in props.get("cuts", []): - source = cut.get("source", "") - if source and not source.startswith(("http://", "https://", "file://")): - resolved = Path(source).resolve() - if resolved.exists(): - posix = resolved.as_posix() - cut["source"] = f"file:///{posix}" if not posix.startswith("/") else f"file://{posix}" - # Build a custom themeConfig from the playbook's actual colors. # This ensures every video gets a unique visual identity derived # from its production decisions — not picked from a preset menu. @@ -1723,11 +1896,6 @@ class VideoCompose(BaseTool): if theme_config: props["themeConfig"] = theme_config - # Write props to temp file for Remotion CLI - props_path = output_path.parent / ".remotion_props.json" - with open(props_path, "w", encoding="utf-8") as f: - json.dump(props, f) - # remotion-composer lives at project root composer_dir = Path(__file__).resolve().parent.parent.parent / "remotion-composer" if not composer_dir.exists(): @@ -1741,6 +1909,39 @@ class VideoCompose(BaseTool): renderer_family = (composition_data or {}).get("renderer_family", "explainer-data") composition_id = self._get_composition_id(renderer_family) + if composition_id == "CinematicRenderer": + if not props.get("scenes") and props.get("cuts"): + props["scenes"] = self._cuts_to_cinematic_scenes(props["cuts"]) + props.pop("cuts", None) + if not props.get("scenes"): + return ToolResult( + success=False, + error="CinematicRenderer received cuts but none could be adapted into scenes.", + ) + + requested_public_dir = inputs.get("public_dir") + cleanup_public_dir = False + public_dir: Path | None = None + if requested_public_dir: + public_dir = Path(requested_public_dir).resolve() + if not public_dir.is_dir(): + return ToolResult( + success=False, + error=f"Remotion public_dir does not exist or is not a directory: {public_dir}", + ) + else: + public_dir = output_path.parent / f".remotion-public-{output_path.stem}" + cleanup_public_dir = True + + staged_count = self._stage_remotion_media(props, public_dir) + if not staged_count and cleanup_public_dir: + public_dir = None + + # Write the fully adapted/staged props, never the original cut payload. + props_path = output_path.parent / ".remotion_props.json" + with open(props_path, "w", encoding="utf-8") as f: + json.dump(props, f) + cmd = [ "npx", "remotion", "render", str(composer_dir / "src" / "index.tsx"), @@ -1753,6 +1954,8 @@ class VideoCompose(BaseTool): # API Remotion recommends for file paths and is cross-platform safe. f"--props={props_path}", ] + if public_dir is not None: + cmd.append(f"--public-dir={public_dir}") # Apply media profile dimensions profile_name = inputs.get("profile") @@ -1770,7 +1973,8 @@ class VideoCompose(BaseTool): # opaque failure. Pass it through and give the subprocess enough headroom # so run_command() does not kill Remotion before its own timeout fires. remotion_timeout_ms = inputs.get("remotion_timeout_ms") - subprocess_timeout = 600 + scene_count = len(props.get("scenes") or props.get("cuts") or []) + subprocess_timeout = max(600, scene_count * 15) if remotion_timeout_ms: try: ms = int(remotion_timeout_ms) @@ -1808,6 +2012,8 @@ class VideoCompose(BaseTool): finally: if props_path.exists(): props_path.unlink() + if cleanup_public_dir and public_dir is not None and public_dir.exists(): + shutil.rmtree(public_dir, ignore_errors=True) if not output_path.exists(): return ToolResult( @@ -1821,6 +2027,7 @@ class VideoCompose(BaseTool): "operation": "remotion_render", "output": str(output_path), "profile": profile_name, + "staged_media_count": staged_count, }, artifacts=[str(output_path)], ) From 2702362c24ad64f215f364bde488cb2a8b8126fb Mon Sep 17 00:00:00 2001 From: calesthio Date: Thu, 13 Aug 2026 07:48:38 -0700 Subject: [PATCH 09/10] feat: add production 3D world pipeline --- .agents/skills/3d-asset-generation/SKILL.md | 101 +++ .../3d-asset-generation/agents/openai.yaml | 4 + .../skills/threejs-world-generation/SKILL.md | 130 ++++ .../agents/openai.yaml | 4 + .../references/world-spec.md | 136 ++++ .../references/worldclaw-principles.md | 39 + .gitignore | 4 + AGENT_GUIDE.md | 6 +- PROJECT_CONTEXT.md | 8 +- pipeline_defs/animation.yaml | 16 +- pipeline_defs/cinematic.yaml | 15 +- schemas/artifacts/asset_manifest.schema.json | 2 +- schemas/tools/atlas_3d.schema.json | 24 + schemas/tools/blender_world.schema.json | 23 + schemas/tools/fal_3d.schema.json | 20 + .../tools/threejs_asset_catalog.schema.json | 16 + schemas/tools/threejs_world.schema.json | 37 + skills/INDEX.md | 8 +- skills/core/hyperframes.md | 8 +- skills/creative/3d-world-generation.md | 48 ++ skills/meta/animation-runtime-selector.md | 4 + skills/meta/bespoke-composition.md | 9 +- skills/pipelines/animation/asset-director.md | 9 +- .../pipelines/animation/compose-director.md | 4 +- .../pipelines/animation/proposal-director.md | 1 + skills/pipelines/cinematic/asset-director.md | 14 + .../pipelines/cinematic/compose-director.md | 6 +- .../pipelines/cinematic/proposal-director.md | 6 + tests/tools/test_3d_asset_generation.py | 143 ++++ tests/tools/test_threejs_asset_catalog.py | 45 ++ tests/tools/test_threejs_world.py | 243 ++++++ tools/graphics/atlas_3d.py | 227 ++++++ tools/graphics/blender_world.py | 241 ++++++ tools/graphics/fal_3d.py | 213 +++++ .../templates/blender-world-runtime.py | 434 +++++++++++ .../templates/threejs_world/index.html | 66 ++ .../templates/threejs_world/world-runtime.js | 478 ++++++++++++ .../templates/threejs_world/world.css | 79 ++ tools/graphics/threejs_asset_catalog.py | 171 ++++ tools/graphics/threejs_world.py | 732 ++++++++++++++++++ tools/video/hyperframes_compose.py | 196 ++++- tools/video/video_compose.py | 36 +- 42 files changed, 3979 insertions(+), 27 deletions(-) create mode 100644 .agents/skills/3d-asset-generation/SKILL.md create mode 100644 .agents/skills/3d-asset-generation/agents/openai.yaml create mode 100644 .agents/skills/threejs-world-generation/SKILL.md create mode 100644 .agents/skills/threejs-world-generation/agents/openai.yaml create mode 100644 .agents/skills/threejs-world-generation/references/world-spec.md create mode 100644 .agents/skills/threejs-world-generation/references/worldclaw-principles.md create mode 100644 schemas/tools/atlas_3d.schema.json create mode 100644 schemas/tools/blender_world.schema.json create mode 100644 schemas/tools/fal_3d.schema.json create mode 100644 schemas/tools/threejs_asset_catalog.schema.json create mode 100644 schemas/tools/threejs_world.schema.json create mode 100644 skills/creative/3d-world-generation.md create mode 100644 tests/tools/test_3d_asset_generation.py create mode 100644 tests/tools/test_threejs_asset_catalog.py create mode 100644 tests/tools/test_threejs_world.py create mode 100644 tools/graphics/atlas_3d.py create mode 100644 tools/graphics/blender_world.py create mode 100644 tools/graphics/fal_3d.py create mode 100644 tools/graphics/templates/blender-world-runtime.py create mode 100644 tools/graphics/templates/threejs_world/index.html create mode 100644 tools/graphics/templates/threejs_world/world-runtime.js create mode 100644 tools/graphics/templates/threejs_world/world.css create mode 100644 tools/graphics/threejs_asset_catalog.py create mode 100644 tools/graphics/threejs_world.py diff --git a/.agents/skills/3d-asset-generation/SKILL.md b/.agents/skills/3d-asset-generation/SKILL.md new file mode 100644 index 00000000..6a7416a2 --- /dev/null +++ b/.agents/skills/3d-asset-generation/SKILL.md @@ -0,0 +1,101 @@ +--- +name: 3d-asset-generation +description: Generate, reconstruct, inspect, and route production 3D assets for OpenMontage worlds using Atlas Cloud, fal.ai, licensed catalogs, and Blender. +--- + +# 3D Asset Generation + +Use this skill when a production needs real meshes rather than primitive stand-ins. +It complements `threejs-world-generation`: that skill owns semantic world planning; +this skill owns how unique and repeated meshes enter the world with provenance. + +## Route by asset role + +| Need | Tool | Model/path | Why | +|---|---|---|---| +| Repeated vegetation, rocks, generic props | `threejs_asset_catalog` | CC0 Kenney catalog | Free, coherent, instancing-friendly | +| Unique object described in words | `atlas_3d` | `tripo-h3.1/text-to-3d` | Direct textured/PBR GLB with seeds and face limit | +| Object matching a concept image | `fal_3d` | Hunyuan 3D v3.1 Rapid image-to-3D | Better silhouette/style conditioning from one image | +| Several objects extracted from one regional composition | `fal_3d` | SAM 3D Objects | Individual and combined GLBs plus placement metadata | +| Terrain, composition, lighting, camera, final frames | `blender_world` | Blender 4.5 LTS / Eevee Next | Scene-level control; generated-asset APIs are not world renderers | + +Never ask a text-to-3D model to generate a whole cinematic world in one mesh. +Generate hero objects, use licensed catalogs for high-volume scatter, and assemble +everything in Blender from a semantic specification. + +## Paid-call discipline + +Before every first provider call, state the exact provider, model, operation, +estimated unit cost, and number of requested outputs. Generate one sample before +a batch. As of 2026-08-13: + +- Atlas Tripo H3.1: $0.22 untextured; $0.33 standard textures; $0.44 HD + textures; detailed geometry adds $0.22; quad mesh adds $0.055. +- fal Hunyuan 3D v3.1 Rapid: $0.225 per generation; PBR adds $0.15. +- fal SAM 3D Objects: $0.02 per reconstruction. + +Pricing changes. Confirm the provider page before quoting or running a batch. + +## Asset prompt contract + +Each request describes one isolated object, not a shot: + +1. Name the object and silhouette. +2. Specify construction materials and visible wear. +3. Specify the project's art style and color constraints. +4. State scale and orientation. +5. Exclude ground plane, backdrop, extra objects, labels, and lighting rigs. + +For image-to-3D, use a simple background and make the object occupy more than +half the frame. Request PBR only for assets close enough to benefit from it. + +## Mandatory mesh QA + +Do not approve from the provider thumbnail alone. Import the downloaded artifact +into Blender and inspect: + +- front, rear, and silhouette; +- geometry holes and floating pieces; +- ground contact, scale, and orientation; +- UV seams and missing texture slots; +- base-color, roughness, metallic, and normal response; +- triangle count and whether the intended camera distance justifies it. + +Record provider, model id, prompt, seeds, source page, cost, and output path in +the asset provenance manifest. Failed samples remain failed; do not silently +swap provider or spend on another model. + +### Assembly normalization is mandatory + +Provider and catalog GLBs rarely share units, origins, or up-axis assumptions. +Never compensate with arbitrary scene-level scale guesses. The Blender assembly +spec declares `target_height`; the renderer measures the imported bounding box, +normalizes to that target, and offsets the bounding-box floor to the sampled +terrain height. Review the resulting real-world scale and ground contact. + +Repeated scatter must declare semantic `exclusion_zones` around settlements, +roads, rivers, landmark apertures, and hero camera sightlines. Density that +occludes the subject is not production detail. Waterways and paths must use flat +terrain-following ribbons; beveled 3D curves read as pipes from aerial cameras. + +Landmarks whose reveal timing matters declare visibility windows in the scene +spec. Camera occlusion remains preferred for natural reveals, but deterministic +visibility keys are the hard guarantee for approved timing. + +## World fidelity budget + +A reference-grade region needs three simultaneous density layers: + +- macro: authored terrain silhouettes, waterways, paths, settlements; +- meso: hero buildings, bridges, cliffs, canopy clusters, props; +- micro: ground cover, rocks, flowers, debris, material breakup. + +The asset gate must show global, regional, and walk-height Blender stills. A +wide aerial alone can hide broken contacts; a walk shot alone can hide an empty +world. Primitive-only previews must be labeled `blockout` and cannot pass as a +production-fidelity review. + +For a final animation, render a small bounded frame range first and measure the +per-frame time. Choose the full-render resolution from that measurement rather +than intuition, render a numbered PNG sequence, and call `blender_world` with +`resume: true` after interruption so it starts at the first missing frame. diff --git a/.agents/skills/3d-asset-generation/agents/openai.yaml b/.agents/skills/3d-asset-generation/agents/openai.yaml new file mode 100644 index 00000000..9c385d52 --- /dev/null +++ b/.agents/skills/3d-asset-generation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "3D Asset Generation" + short_description: "Generate and assemble production 3D assets" + default_prompt: "Use $3d-asset-generation to source, generate, validate, and assemble production-ready 3D assets for this world." diff --git a/.agents/skills/threejs-world-generation/SKILL.md b/.agents/skills/threejs-world-generation/SKILL.md new file mode 100644 index 00000000..1ced8df4 --- /dev/null +++ b/.agents/skills/threejs-world-generation/SKILL.md @@ -0,0 +1,130 @@ +--- +name: threejs-world-generation +description: Build deterministic, editable, free-viewpoint Three.js worlds from text or structured briefs. Use for cinematic 3D terrain, semantic regions, procedural biomes, explicit landmarks, environmental scattering, camera fly-throughs, world diagnostics, or requests for a real 3D environment rather than generated 2D footage. Integrates OpenMontage's threejs_world tool with HyperFrames; do not use for a single isolated 3D object or a flat parallax scene. +--- + +# Three.js World Generation + +For production meshes and Blender assembly, also read `3d-asset-generation`. +Three.js remains the semantic interactive/blockout renderer; Blender is the +production renderer when the brief calls for dense reference-grade scenery. + +The production handoff must include target dimensions for imported assets, +semantic scatter exclusion zones, terrain-following water/path geometry, +landmark visibility policy, camera clearance, and global/regional/walk review +frames. These are world-spec contracts, not manual Blender cleanup notes. + +Create a persistent scene graph, not a sequence of unrelated 2D shots. Preserve the user's explicit constraints, infer missing construction details separately, establish the global terrain first, and refine selected regions without disturbing the world-wide spatial contract. + +## Choose the fidelity tier explicitly + +- `blockout`: procedural primitives, vertex colors, semantic/layout validation, fast iteration. Never call this production-quality, reference-grade, or visually equivalent to WorldClaw. +- `production`: licensed local GLTF/GLB catalogs, a minimum eight-model palette across four semantic categories, three PBR terrain layers, asset provenance, walk-level repetition review, and no primitive landmark fallback. + +For a hero video or any reference showing populated textured environments, use `production`. If its catalog/material/provider requirements cannot be met, stop at preflight or the asset gate. Do not render a blockout as the final deliverable. + +## Read first + +- Read [references/worldclaw-principles.md](references/worldclaw-principles.md) when planning or explaining the coarse-to-fine method. +- Read [references/world-spec.md](references/world-spec.md) before authoring a `world_spec` or calling `threejs_world`. +- Read `hyperframes-core`, `hyperframes-animation`, and `hyperframes-animation/adapters/three.md` before editing the emitted workspace. +- Read `threejs-loaders`, `threejs-materials`, `threejs-textures`, `threejs-lighting`, and `threejs-postprocessing` for production-tier work. + +## Route the request + +- Use the `animation` pipeline for design-led, explanatory, abstract, or music-led world films. +- Use the `cinematic` pipeline for trailer-like mood, dramatic reveals, or source-plus-world edits. +- Choose HyperFrames when the deliverable is the code-native Three.js world. Choose Blender for reference-grade hero rendering and FFmpeg only to package Blender's numbered frames and approved audio. Record that choice at proposal; do not silently switch after approval. +- Keep this as a capability inside existing pipelines. Do not create a new pipeline merely because a scene is 3D. + +## Workflow + +### 1. Separate intent from completion + +Record two lists before planning: + +- `explicit_constraints`: only facts the user supplied. +- `inferred_details`: scale, region coverage, terrain operators, densities, palette refinements, and camera details added to make the world executable. + +Never smuggle an inferred landmark, biome, or story beat into the explicit list. + +### 2. Plan globally + +Author one shared `world_spec` containing: + +- world scale, terrain resolution, elevation range, and seed; +- semantic regions with normalized centers, radii, landform operators, palette, and scatter recipes; +- atmosphere and lighting shared across all regions; +- explicit landmarks with stable IDs and world-space placement; +- a complete camera path with time, position, target, and field of view. + +Prefer 3-7 regions. Each region must contribute a distinct silhouette, surface read, or functional role. + +### 3. Build the terrain foundation + +For production, first call `threejs_asset_catalog` to install rights-safe catalogs under `projects//assets/3d/catalogs//`. Record source, license, archive hash, model inventory, and every selected model in the asset manifest. Then call `threejs_world` with `quality_tier: "production"` and the installed catalog paths. + +```python +from tools.graphics.threejs_world import ThreeJSWorld + +result = ThreeJSWorld().execute({ + "operation": "build", + "world_spec": world_spec, + "output_path": "projects//hyperframes", + "duration_seconds": 60, + "render_mode": "cinematic", + "quality_tier": "production", + "asset_catalog_paths": ["projects//assets/3d/catalogs/kenney-nature-kit"], +}) +``` + +Treat `world.json`, `world-spec.js`, `world-runtime.js`, and `world-report.json` as editable assets. Do not flatten them into a video until the assets gate is approved. + +### 4. Inspect regionally + +Build a second pass with `render_mode: "semantic"` or `"wireframe"` when spatial problems are hard to see in the cinematic material pass. Inspect snapshots from global, regional, and walk-level viewpoints. + +Maintain an issue queue with stable subjects: + +- terrain transition or silhouette; +- landmark scale, pose, or contact; +- scatter density, slope rejection, or repetition; +- material contrast and atmosphere; +- camera clearance, clipping, or weak framing. + +Fix only the affected region or object when possible. Preserve the seed, region IDs, camera times, and unrelated parameters. + +### 5. Refine with bounded loops + +Run at most three render-guided refinement rounds: + +1. build the workspace; +2. run the unified HyperFrames `check` gate and snapshot representative times; +3. inspect frames and update the issue queue; +4. change the narrowest relevant spec fields; +5. rebuild with the same seed and compare. + +Stop when no substantial issue remains or the iteration budget is reached. Report residual limitations rather than disguising them with overlays. + +### 6. Compose without overwriting + +For browser-native delivery, set `render_runtime: "hyperframes"` and `composition_mode: "atelier"`; `video_compose` must preserve the authored workspace. For reference-grade video, render a Blender PNG sequence with `resume: true`, then set `render_runtime: "ffmpeg"` for packaging. Preserve the world spec and `.blend` as the editable source of truth. + +## Quality gates + +- Terrain is continuous and region boundaries blend without obvious seams. +- Every landmark touches its support surface and remains inside world bounds. +- Scatter respects region affinity, slope limits, and deterministic seed behavior. +- Global, regional, and walk-level frames all read as the same continuous world. +- Camera paths remain above terrain, avoid clipping, and provide at least one scale-establishing reveal. +- World source remains editable after render: regions, landmarks, camera keys, and palette have stable IDs or fields. +- HyperFrames `check` and post-render review pass before delivery. Use the legacy `validate` or `inspect` operations only when supporting an older runtime. +- Production beauty frames contain textured assets at foreground, midground, and background depths; no dominant object may read as an untextured box, cone, octahedron, or dodecahedron. +- Production requires at least four semantic asset categories, eight distinct models, three PBR terrain layers, one regional composition review per camera-critical region, and explicit repetition/contact findings. + +## Boundaries + +- The production catalog path materially improves geometry and surface richness, but it still does not reproduce WorldClaw's GPT-Image-2, SAM3, SAM3D, Hunyuan3D, BlenderMCP, or four-H20 implementation. +- Do not claim articulated assets, game physics, navigation meshes, or interaction logic unless another tool explicitly adds them. +- Do not use unseeded randomness, wall-clock animation, remote models, or render-time asset fetches. +- Do not delete the lower-level `threejs-*` skills. They are the subsystem references used when extending this runtime. diff --git a/.agents/skills/threejs-world-generation/agents/openai.yaml b/.agents/skills/threejs-world-generation/agents/openai.yaml new file mode 100644 index 00000000..151a3232 --- /dev/null +++ b/.agents/skills/threejs-world-generation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Three.js World Generation" + short_description: "Build semantic, editable cinematic 3D worlds" + default_prompt: "Use $threejs-world-generation to turn this world brief into a deterministic, editable Three.js world and camera sequence." diff --git a/.agents/skills/threejs-world-generation/references/world-spec.md b/.agents/skills/threejs-world-generation/references/world-spec.md new file mode 100644 index 00000000..aa4a79fd --- /dev/null +++ b/.agents/skills/threejs-world-generation/references/world-spec.md @@ -0,0 +1,136 @@ +# `threejs_world` specification + +Use normalized region coordinates in `[-1, 1]`; the tool converts them to world space. Positions for landmarks and cameras are world-space `[x, y, z]` values. + +## Minimal shape + +```json +{ + "version": "1.0", + "title": "The Luminous Divide", + "seed": 2048, + "explicit_constraints": ["a volcanic rift divides a living valley"], + "inferred_details": ["three semantic regions", "dawn atmosphere"], + "world": { + "size": 120, + "resolution": 160, + "elevation_scale": 18, + "water_level": -1.5 + }, + "atmosphere": { + "sky_color": "#07111f", + "fog_color": "#13263a", + "fog_density": 0.009, + "sun_color": "#ffd7a3", + "sun_intensity": 3.2, + "sun_position": [45, 70, 20] + }, + "terrain_materials": [ + { + "id": "mossy-ground", + "regions": ["ember-rift"], + "base_color": "assets/materials/mossy-ground/diffuse.jpg", + "normal": "assets/materials/mossy-ground/normal.jpg", + "roughness": "assets/materials/mossy-ground/roughness.jpg", + "meters_per_repeat": 7 + } + ], + "asset_palette": [ + { + "id": "rift-tree-a", + "catalog_id": "kenney-nature-kit", + "model_id": "tree-pine-a", + "category": "tree", + "region_id": "ember-rift", + "count": 80, + "scale_range": [0.8, 1.5] + } + ], + "regions": [ + { + "id": "ember-rift", + "label": "Ember Rift", + "center": [-0.35, 0.12], + "radius": 0.58, + "base_elevation": 0.3, + "amplitude": 1.0, + "frequency": 1.4, + "landform": "ridge", + "blend_width": 0.2, + "color": "#5b271f", + "accent_color": "#ff6b2c", + "scatter": {"rock": 90, "crystal": 22, "tree": 0} + } + ], + "landmarks": [ + { + "id": "rift-gate", + "type": "arch", + "region_id": "ember-rift", + "position": [-24, 0, 8], + "scale": 5.5, + "color": "#2a2020", + "accent_color": "#ff7a35" + } + ], + "camera_path": [ + {"time": 0, "position": [72, 42, 72], "target": [0, 3, 0], "fov": 46}, + {"time": 60, "position": [-54, 13, -32], "target": [-12, 4, 5], "fov": 40} + ] +} +``` + +## Regions + +Required: `id`, `center`, `radius`, `color`. + +Useful fields: + +- `label`: human-facing diagnostic label. +- `base_elevation`: normalized vertical offset before `elevation_scale`. +- `amplitude`: relief contribution. +- `frequency`: macro noise frequency. +- `landform`: `plain`, `peak`, `ridge`, `dune`, `terrace`, `basin`, or `canyon`. +- `blend_width`: softness of the semantic boundary. +- `accent_color`: used by semantic and environmental details. +- `scatter`: counts for `tree`, `rock`, and `crystal` prototypes. +- `slope_limit`: maximum accepted slope proxy for scattered instances. + +Region weights are normalized at every terrain sample. The fallback region must still cover the full domain, so avoid tiny isolated regions with no broad neighbor. + +## Landmarks + +Supported procedural types: `monolith`, `arch`, `tower`, `ruin`, `crystal`, `settlement`, and `ring`. + +Each landmark is placed at sampled terrain height. `position[1]` is an additional vertical offset, not an absolute Y coordinate. Keep IDs stable through refinement so review notes remain addressable. + +## Camera path + +- Provide at least two keys. +- First key time must be `0`; last key should match the requested duration. +- Keep keys ordered and inside the duration. +- The runtime interpolates position, target, and FOV with smoothstep easing. +- Add higher keys for regional and walk-level passes; do not attempt to encode cuts with teleporting adjacent keys. +- Keep the camera at least `2` world units above sampled terrain unless a deliberate ground skim is reviewed. + +## Render modes + +- `cinematic`: PBR vertex colors, fog, water, shadows, overlays. +- `semantic`: saturated region colors and labels for layout diagnosis. +- `wireframe`: terrain topology and explicit scene nodes for geometry diagnosis. + +## Tool outputs + +`operation: "build"` writes: + +- `index.html`: HyperFrames composition root. +- `world.json`: normalized editable specification. +- `world-spec.js`: browser-loadable specification. +- `world-runtime.js`: deterministic Three.js scene construction. +- `world.css`: full-frame canvas and production overlays. +- `world-report.json`: validation, performance estimate, and warnings. +- `hyperframes.json`: local registry configuration. + +Production builds additionally write `asset-catalog-index.json` and `asset-catalog.js`, and copy the selected catalogs into `assets/models/` so rendering never depends on a remote model fetch. + +`operation: "validate"` performs specification checks without writing a workspace. diff --git a/.agents/skills/threejs-world-generation/references/worldclaw-principles.md b/.agents/skills/threejs-world-generation/references/worldclaw-principles.md new file mode 100644 index 00000000..e3b5f383 --- /dev/null +++ b/.agents/skills/threejs-world-generation/references/worldclaw-principles.md @@ -0,0 +1,39 @@ +# WorldClaw principles adapted for OpenMontage + +Source: [WorldClaw: Agentic 3D Open-World Generation at Scale](https://arxiv.org/html/2608.05248v1), Guo et al., arXiv:2608.05248v1 (2026). + +WorldClaw's public repository currently contains the paper and assets, not the executable generation stack. OpenMontage therefore adopts the architectural ideas, not private code or model weights. + +## Transferable architecture + +1. **Intent extraction precedes completion.** Keep user-stated facts separate from inferred construction parameters. +2. **Shared structured intermediates coordinate agents.** A world spec carries regions, terrain, objects, appearance, and spatial relations across stages. +3. **Global constraints precede local detail.** Establish semantic layout, scale, terrain, atmosphere, and major relationships once. +4. **Terrain is the spatial contract.** Use the same region weights for height, palette, scattering, and later placement. +5. **Reusable environmental prototypes differ from functional landmarks.** Scatter rocks, vegetation, and crystals globally; place named structures explicitly. +6. **Local development is selective.** Spend detail and iteration on regions that matter to the camera path or delivery promise. +7. **Objects stay independent.** Stable IDs and transforms preserve editability and replacement. +8. **Placement is contact-aware.** Sample terrain height and slope, reject implausible candidates, align instances, and diagnose floating or penetration. +9. **Refinement is render-guided and bounded.** Use global, regional, walk, semantic, and wireframe views; change the narrowest responsible parameters. +10. **Executable representations improve reuse.** Code-native terrain, materials, placement, and camera paths remain parameterized and animatable. + +## Local mapping + +| WorldClaw concept | OpenMontage implementation | +|---|---| +| Structured scene specification | `world_spec` JSON and tool schema | +| Semantic layout map | Continuous normalized region-weight field | +| Region-aware height field | Weighted procedural landform operators | +| Terrain materials | Blockout: vertex colors. Production: catalogued PBR texture layers | +| Reusable terrain assets | Blockout: primitives. Production: licensed textured GLTF/GLB palettes | +| Regional objects | Stable explicit scene nodes; production forbids primitive landmark fallback | +| Blender refinement agents | HyperFrames snapshots plus agent issue queue | +| Free-viewpoint render | Deterministic Three.js camera path responding to `hf-seek` | +| Editable textured meshes | Editable code-native geometry, materials, regions, and transforms | + +## Deliberate scope differences + +- No single-view object reconstruction, segmentation, or generated PBR texture maps. +- No Blender or Unreal dependency in the current runnable path; this limits reconstruction and offline-render fidelity. +- No claim of photoreal asset diversity comparable to large generative 3D models. +- Stronger portability and determinism for browser-rendered OpenMontage video work. diff --git a/.gitignore b/.gitignore index 52051e5d..85473523 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,7 @@ venv/ # Backlot local cache (thumbnails) .backlot/ + +# Workspace-local third-party runtimes (for example the portable Blender LTS +# used by blender_world). These are checksum-verified but never committed. +.runtime/ diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index fe464643..9aa14c7e 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -474,6 +474,10 @@ Key capability families to look for in the output: - **analysis** — Transcription, scene detection, frame sampling. - **avatar** — Talking head and lip sync generation. - **character_animation** — Local character specs, SVG rigs, pose libraries, action timelines, previews, and QA. +- **3d_world_generation** — Local semantic terrain, procedural biome scattering, explicit landmarks, diagnostic passes, and deterministic HyperFrames/Three.js camera fly-throughs. Route through `threejs_world` and read `skills/creative/3d-world-generation.md` plus `.agents/skills/threejs-world-generation/SKILL.md`. +- **3d_asset_acquisition** — Rights-safe GLTF/GLB catalogs for production-tier Three.js worlds. Route through `threejs_asset_catalog`; never substitute blockout primitives for a requested detailed or reference-grade environment. +- **3d_asset_generation** — Unique textured/PBR meshes from text or concept images. Route text-described hero assets through `atlas_3d`, image-conditioned assets and regional object extraction through `fal_3d`, and read `.agents/skills/3d-asset-generation/SKILL.md`. Announce provider/model/unit cost before every first paid call and sample before batching. +- **3d_world_rendering** — Production assembly, terrain, lighting, materials, camera, and image-sequence rendering in Blender. Route through `blender_world`; use Three.js for interactive/blockout review, not as a substitute for Blender when reference-grade scene density is requested. - **enhancement** — Upscale, background removal, face enhance, color grading. Each tool in the registry declares `best_for`, `install_instructions`, `runtime` (LOCAL, API, LOCAL_GPU, HYBRID), and `status`. Read these fields — do not assume tool strengths from memory. @@ -673,7 +677,7 @@ The `.agents/skills/` directory is large. When you're not coming in through a to | Category | Skills | |---|---| -| **Composition runtime** | `remotion`, `remotion-best-practices`, `synthetic-screen-recording` (fake terminal/UI demos via Remotion TerminalScene) | +| **Composition runtime** | `remotion`, `remotion-best-practices`, `synthetic-screen-recording` (fake terminal/UI demos via Remotion TerminalScene), `threejs-world-generation` (semantic terrain and free-viewpoint HyperFrames worlds) | | **Animation knowledge (generic)** | `gsap-core`, `gsap-timeline`, `gsap-plugins` (SplitText / MorphSVG / DrawSVG / MotionPath / Flip / CustomEase), `gsap-utils`, `gsap-react`, `gsap-performance`, `gsap-scrolltrigger`, `gsap-frameworks`, `framer-motion` (Disney 12 principles), `lottie-bodymovin` (Lottie export) | | **Character animation** | `character-rigging`, `svg-character-animation`, `pose-library-design`, `canvas-procedural-animation`, `character-animation-qa` | | **Image generation** | `bfl-api`, `flux-best-practices` | diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 136856bd..5f0db301 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -71,11 +71,17 @@ Each tool's `agent_skills[]` field bridges Layer 1 → Layer 3. See `skills/INDE | `tools/cost_tracker.py` | Budget governance | | `tools/video/video_stitch.py` | Multi-clip assembly (stitch, spatial, validate, preview) | | `tools/video/video_compose.py` | Runtime-aware composition orchestrator — routes to Remotion / HyperFrames / FFmpeg based on `edit_decisions.render_runtime` | -| `tools/video/hyperframes_compose.py` | HyperFrames runtime — workspace materialization, `hyperframes lint`/`validate`/`render`, FFmpeg floor check | +| `tools/video/hyperframes_compose.py` | HyperFrames runtime — templated workspace materialization plus authored-workspace unified `check`/`render`, FFmpeg floor check | +| `tools/graphics/threejs_world.py` | Local semantic 3D-world authoring with explicit blockout/production fidelity tiers, region-aware terrain, diagnostics, and HyperFrames atelier workspaces | +| `tools/graphics/threejs_asset_catalog.py` | CC0 GLTF/GLB catalog acquisition, inventory, and provenance for production-fidelity world builds | +| `tools/graphics/atlas_3d.py` | Atlas Cloud Tripo H3.1 text-to-3D for unique textured/PBR GLB assets | +| `tools/graphics/fal_3d.py` | fal.ai Hunyuan 3D and SAM 3D routes for image-conditioned and multi-object GLB generation | +| `tools/graphics/blender_world.py` | Blender 4.5 LTS production world assembly, terrain, lighting, camera, and Eevee Next rendering | | `tools/character/character_animation.py` | Local character-animation tools — character specs, SVG rig plans, pose libraries, action timelines, HyperFrames packages, and QA reports | | `lib/hyperframes_style_bridge.py` | Playbook → CSS custom properties + `DESIGN.md` bridge for HyperFrames workspaces | | `remotion-composer/src/components/` | 8 Remotion components (TextCard, StatCard, ProgressBar, CalloutBox, ComparisonCard + charts/) | | `.agents/skills/hyperframes*/` | Vendored HyperFrames Layer 3 skills (authoring contract, CLI, registry, website-to-video) | +| `.agents/skills/threejs-world-generation/` | Layer 3 coarse-to-fine semantic world construction and render-guided refinement workflow | | `skills/core/hyperframes.md` | Layer 2 — when OpenMontage should pick HyperFrames vs Remotion, artifact → workspace mapping | | `schemas/styles/playbook.schema.json` | Playbook schema v2 with design tokens (chart_palette, scale_system, weight_matrix, color_rules) | | `tests/qa/` | Quality validation test scripts for tool-by-tool output inspection | diff --git a/pipeline_defs/animation.yaml b/pipeline_defs/animation.yaml index 1002eabf..20f964a9 100644 --- a/pipeline_defs/animation.yaml +++ b/pipeline_defs/animation.yaml @@ -2,7 +2,7 @@ name: animation version: "2.0" description: > Animation-first pipeline for motion graphics, diagram-led explainers, kinetic typography, - math visuals, and stylized illustrative sequences. Features a research-first pre-production + math visuals, explicit Three.js worlds, and stylized illustrative sequences. Features a research-first pre-production phase: the agent researches the topic and animation techniques, proposes concepts with animation mode selection and cost estimates, and gets explicit user approval before any assets are generated. @@ -175,14 +175,18 @@ stages: - proposal_packet produces: - asset_manifest - required_tools: - - tts_selector optional_tools: + - tts_selector - image_selector - video_selector - math_animate - diagram_gen - code_snippet + - threejs_world + - threejs_asset_catalog + - atlas_3d + - fal_3d + - blender_world - music_gen tools_available: - tts_selector @@ -191,6 +195,11 @@ stages: - math_animate - diagram_gen - code_snippet + - threejs_world + - threejs_asset_catalog + - atlas_3d + - fal_3d + - blender_world - music_gen checkpoint_required: true human_approval_default: true @@ -208,6 +217,7 @@ stages: - Schema-valid asset_manifest artifact - All referenced asset files exist on disk - layer3_skills_read list is present and includes all tools used for generation + - Real 3D-world briefs include semantic or wireframe diagnostic review before compose - name: edit skill: pipelines/animation/edit-director diff --git a/pipeline_defs/cinematic.yaml b/pipeline_defs/cinematic.yaml index 0f806d6b..23f44201 100644 --- a/pipeline_defs/cinematic.yaml +++ b/pipeline_defs/cinematic.yaml @@ -1,7 +1,8 @@ name: cinematic version: "2.0" description: > - Mood-led cinematic pipeline for trailers, brand films, montages, and short-form dramatic edits. + Mood-led cinematic pipeline for trailers, brand films, montages, explicit 3D-world fly-throughs, + and short-form dramatic edits. Works best with supplied footage, stills, or source media, and can optionally use generated support visuals for gap filling or concept-led inserts. EP orchestration adds quality gates for emotional pacing, color consistency, and audio dynamics. @@ -171,6 +172,11 @@ stages: - audio_enhance - image_selector - video_selector + - threejs_world + - threejs_asset_catalog + - atlas_3d + - fal_3d + - blender_world - pixabay_music - freesound_music - music_gen @@ -179,6 +185,11 @@ stages: - audio_enhance - image_selector - video_selector + - threejs_world + - threejs_asset_catalog + - atlas_3d + - fal_3d + - blender_world - pixabay_music - freesound_music - music_gen @@ -189,6 +200,8 @@ stages: - Motion-required beats use actual video clips rather than still-image substitutes - Music and ambience plan matches the beat map - Optional generated inserts stay limited and justified + - Explicit 3D worlds preserve one coherent scene graph across global, regional, and walk views + - Hero 3D worlds use the production fidelity tier with licensed textured models and PBR terrain layers; primitive-only blockouts cannot pass success_criteria: - Schema-valid asset_manifest artifact - All referenced asset files exist on disk diff --git a/schemas/artifacts/asset_manifest.schema.json b/schemas/artifacts/asset_manifest.schema.json index 291b0b05..57324e36 100644 --- a/schemas/artifacts/asset_manifest.schema.json +++ b/schemas/artifacts/asset_manifest.schema.json @@ -16,7 +16,7 @@ "id": { "type": "string" }, "type": { "type": "string", - "enum": ["image", "video", "audio", "narration", "music", "sfx", "diagram", "animation", "code_snippet", "subtitle", "font", "lut"] + "enum": ["image", "video", "audio", "narration", "music", "sfx", "diagram", "animation", "3d_asset", "3d_world", "code_snippet", "subtitle", "font", "lut"] }, "path": { "type": "string", "description": "Relative path within the pipeline project directory" }, "source_tool": { "type": "string" }, diff --git a/schemas/tools/atlas_3d.schema.json b/schemas/tools/atlas_3d.schema.json new file mode 100644 index 00000000..dc3405ca --- /dev/null +++ b/schemas/tools/atlas_3d.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/tools/atlas_3d", + "title": "Atlas Cloud Text-to-3D Input", + "type": "object", + "required": ["prompt", "output_path"], + "properties": { + "prompt": {"type": "string", "minLength": 3, "maxLength": 1024}, + "negative_prompt": {"type": "string"}, + "output_path": {"type": "string"}, + "texture": {"type": "boolean"}, + "pbr": {"type": "boolean"}, + "texture_quality": {"type": "string", "enum": ["standard", "detailed"]}, + "geometry_quality": {"type": "string", "enum": ["standard", "detailed"]}, + "face_limit": {"type": "integer", "minimum": 1000, "maximum": 2000000}, + "model_seed": {"type": "integer"}, + "image_seed": {"type": "integer"}, + "texture_seed": {"type": "integer"}, + "auto_size": {"type": "boolean"}, + "quad": {"type": "boolean"}, + "poll_timeout_seconds": {"type": "integer", "minimum": 30, "maximum": 1800} + }, + "additionalProperties": false +} diff --git a/schemas/tools/blender_world.schema.json b/schemas/tools/blender_world.schema.json new file mode 100644 index 00000000..45015e63 --- /dev/null +++ b/schemas/tools/blender_world.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/tools/blender_world", + "title": "Blender World Input", + "type": "object", + "required": ["operation"], + "properties": { + "operation": {"type": "string", "enum": ["doctor", "build", "render_still", "render_animation"]}, + "world_spec": {"type": "object"}, + "output_path": {"type": "string"}, + "blend_path": {"type": "string"}, + "width": {"type": "integer", "minimum": 320, "maximum": 7680}, + "height": {"type": "integer", "minimum": 240, "maximum": 4320}, + "samples": {"type": "integer", "minimum": 1, "maximum": 256}, + "fps": {"type": "integer", "minimum": 1, "maximum": 120}, + "duration_seconds": {"type": "number", "minimum": 1, "maximum": 600}, + "start_frame": {"type": "integer", "minimum": 1}, + "end_frame": {"type": "integer", "minimum": 1}, + "frame": {"type": "integer", "minimum": 1}, + "resume": {"type": "boolean", "default": false} + }, + "additionalProperties": false +} diff --git a/schemas/tools/fal_3d.schema.json b/schemas/tools/fal_3d.schema.json new file mode 100644 index 00000000..e0ea6716 --- /dev/null +++ b/schemas/tools/fal_3d.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/tools/fal_3d", + "title": "fal.ai 3D Generation Input", + "type": "object", + "required": ["operation", "output_path"], + "properties": { + "operation": {"type": "string", "enum": ["text_to_3d", "image_to_3d", "reconstruct_objects"]}, + "prompt": {"type": "string"}, + "image_url": {"type": "string"}, + "image_path": {"type": "string"}, + "output_path": {"type": "string"}, + "enable_pbr": {"type": "boolean"}, + "seed": {"type": "integer"}, + "export_textured_glb": {"type": "boolean"}, + "detection_threshold": {"type": "number", "minimum": 0.1, "maximum": 1.0}, + "poll_timeout_seconds": {"type": "integer", "minimum": 30, "maximum": 1800} + }, + "additionalProperties": false +} diff --git a/schemas/tools/threejs_asset_catalog.schema.json b/schemas/tools/threejs_asset_catalog.schema.json new file mode 100644 index 00000000..c69eabfb --- /dev/null +++ b/schemas/tools/threejs_asset_catalog.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/tools/threejs_asset_catalog", + "title": "Three.js Asset Catalog Input", + "type": "object", + "required": ["operation"], + "properties": { + "operation": {"type": "string", "enum": ["list", "install", "inspect"]}, + "catalog_id": { + "type": "string", + "enum": ["kenney-nature-kit", "kenney-fantasy-town-kit", "kenney-survival-kit"] + }, + "output_path": {"type": "string"} + }, + "additionalProperties": false +} diff --git a/schemas/tools/threejs_world.schema.json b/schemas/tools/threejs_world.schema.json new file mode 100644 index 00000000..8bd2f36c --- /dev/null +++ b/schemas/tools/threejs_world.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/tools/threejs_world", + "title": "Three.js World Tool Input", + "type": "object", + "required": ["operation", "world_spec"], + "properties": { + "operation": {"type": "string", "enum": ["build", "validate"]}, + "output_path": {"type": "string"}, + "duration_seconds": {"type": "number", "minimum": 1, "maximum": 600, "default": 60}, + "width": {"type": "integer", "minimum": 320, "maximum": 7680, "default": 1920}, + "height": {"type": "integer", "minimum": 240, "maximum": 4320, "default": 1080}, + "render_mode": {"type": "string", "enum": ["cinematic", "semantic", "wireframe"], "default": "cinematic"}, + "quality_tier": {"type": "string", "enum": ["blockout", "production"], "default": "blockout"}, + "asset_catalog_paths": {"type": "array", "items": {"type": "string"}, "default": []}, + "world_spec": { + "type": "object", + "required": ["regions", "camera_path"], + "properties": { + "version": {"type": "string"}, + "title": {"type": "string"}, + "seed": {"type": "integer"}, + "explicit_constraints": {"type": "array", "items": {"type": "string"}}, + "inferred_details": {"type": "array", "items": {"type": "string"}}, + "world": {"type": "object"}, + "atmosphere": {"type": "object"}, + "terrain_materials": {"type": "array", "items": {"type": "object"}}, + "asset_palette": {"type": "array", "items": {"type": "object"}}, + "regions": {"type": "array", "minItems": 1, "maxItems": 12, "items": {"type": "object"}}, + "landmarks": {"type": "array", "items": {"type": "object"}}, + "camera_path": {"type": "array", "minItems": 2, "items": {"type": "object"}} + }, + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/skills/INDEX.md b/skills/INDEX.md index 01481798..d67b098d 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -59,6 +59,10 @@ Key capability families to look for in the output: | `enhancement` | — | Mixed providers | | `analysis` | — | Mixed providers | | `character_animation` | — | Local character specs, SVG rigs, pose libraries, action timelines, previews, and QA | +| `3d_world_generation` | — | Local semantic terrain, procedural scattering, explicit landmarks, diagnostics, and HyperFrames/Three.js fly-through workspaces | +| `3d_asset_acquisition` | — | Rights-safe local GLTF/GLB catalogs and provenance | +| `3d_asset_generation` | — | Atlas/fal textured mesh generation and reconstruction for unique scene assets | +| `3d_world_rendering` | — | Blender assembly and production rendering of detailed worlds | | `graphics` | — | Local rendering tools | | `music_library` | — | Discovers user-provided local tracks | | `music_search` | — | Discovers royalty-free search/download providers | @@ -109,6 +113,7 @@ Key capability families to look for in the output: | ManimCE Usage | `creative/manim-usage.md` | Scene composition, animation timing, color usage | `manimce-best-practices` | | Image Gen Usage | `creative/image-gen-usage.md` | Prompt consistency, hero reference, batch strategy | `flux-best-practices`, `bfl-api` | | Image Provider Usage | `creative/image-provider-usage.md` | Provider selection (FLUX/Grok/OpenAI/Recraft/stock), cost-quality tradeoffs | `flux-best-practices`, `bfl-api`, `grok-media` | +| 3D World Generation | `creative/3d-world-generation.md` | Semantic world planning, asset sourcing/generation, Blender assembly, and fidelity review | `3d-asset-generation`, `threejs-world-generation` | | B-Roll Planning | `creative/broll-planning.md` | Stock vs. generated decision, query construction, footage evaluation | — | | Stock Sourcing Usage | `creative/stock-sourcing-usage.md` | Pexels/Pixabay usage, parameters, licensing, integration | — | | Scene Detect Usage | `creative/scene-detect-usage.md` | Threshold tuning, algorithm selection, content presets | â€" | @@ -131,6 +136,7 @@ Pipeline type skills provide production guidance for specific video formats, ind | Long-Form | `creative/long-form.md` | YouTube 10+ min â€" chapters, retention, end screens | | Screen Recording | `creative/screen-recording.md` | Code walkthroughs, tutorials, software demos | | Animation Pipeline | `creative/animation-pipeline.md` | Motion graphics, easing, transitions, composition | +| 3D World Generation | `creative/3d-world-generation.md` | Continuous Three.js terrain worlds with semantic regions, explicit blockout/production tiers, licensed GLTF/PBR assets, diagnostics, and deterministic camera paths | | Character Animation Pipeline | `pipelines/character-animation/` | Rigged local cartoon characters, pose libraries, action timelines, SVG/Canvas/Remotion/HyperFrames rendering | | Cinematic | `creative/cinematic.md` | Letterbox, film pacing, layered audio, color grading | @@ -312,7 +318,7 @@ Claude Code accesses them via symlinks in `.claude/skills/`. | **TTS & Audio** | `text-to-speech`, `speech-to-text` (whisper, default STT), `azure-speech-to-text` (optional cloud STT), `music`, `sound-effects`, `elevenlabs`, `agents`, `setup-api-key` | `elevenlabs/skills`, `digitalsamba/claude-code-video-toolkit` | | **Image Generation** | `flux-best-practices`, `bfl-api`, `grok-media` | `black-forest-labs/skills`, local OpenMontage skill | | **Math Animation** | `manimce-best-practices`, `manimgl-best-practices`, `manim-composer` | `adithya-s-k/manim_skill` | -| **3D Graphics** | `threejs-animation`, `threejs-fundamentals`, `threejs-geometry`, `threejs-interaction`, `threejs-lighting`, `threejs-loaders`, `threejs-materials`, `threejs-postprocessing`, `threejs-shaders`, `threejs-textures` | `cloudai-x/threejs-skills` | +| **3D Graphics** | `threejs-world-generation` (OpenMontage semantic-world workflow), `threejs-animation`, `threejs-fundamentals`, `threejs-geometry`, `threejs-interaction`, `threejs-lighting`, `threejs-loaders`, `threejs-materials`, `threejs-postprocessing`, `threejs-shaders`, `threejs-textures` | Local OpenMontage skill + `cloudai-x/threejs-skills` | | **Diagrams** | `beautiful-mermaid`, `d3-viz` | `intellectronica/agent-skills`, `davila7/claude-code-templates` | | **Animation** | `framer-motion`, `lottie-bodymovin` | `pproenca/dot-skills`, `dylantarre/animation-principles` | | **Design** | `tailwind-design-system`, `web-design-guidelines`, `vercel-react-best-practices`, `vercel-composition-patterns` | `wshobson/agents`, `vercel-labs/agent-skills` | diff --git a/skills/core/hyperframes.md b/skills/core/hyperframes.md index 120401ef..315fe346 100644 --- a/skills/core/hyperframes.md +++ b/skills/core/hyperframes.md @@ -125,9 +125,11 @@ projects// └── final.mp4 ``` -The workspace is generated at compose time by `hyperframes_compose` from -`edit_decisions` + `asset_manifest` + the active playbook. It's regenerable -and gitignored along with the rest of `projects/`. +Templated workspaces are generated at compose time by `hyperframes_compose` +from `edit_decisions` + `asset_manifest` + the active playbook. Atelier +workspaces are authored during assets and passed through unchanged via +`hyperframes_compose.render_existing`. Both live under `projects/` and are +gitignored with the rest of the production workspace. ### Why a dedicated workspace per project diff --git a/skills/creative/3d-world-generation.md b/skills/creative/3d-world-generation.md new file mode 100644 index 00000000..513a815e --- /dev/null +++ b/skills/creative/3d-world-generation.md @@ -0,0 +1,48 @@ +# 3D World Generation + +Use this Layer 2 skill when an animation or cinematic project needs a real, continuous Three.js environment rather than a stack of stills or generated video clips. + +## Capability route + +1. Query the registry for `3d_world_generation`, `3d_asset_acquisition`, `3d_asset_generation`, and `3d_world_rendering`. +2. Read the selected tools' `agent_skills`; production work requires both `.agents/skills/threejs-world-generation` and `.agents/skills/3d-asset-generation`. +3. Choose the delivery path at proposal. Use `render_runtime="hyperframes"` and `composition_mode="atelier"` for an editable browser-native Three.js deliverable. Use `render_runtime="ffmpeg"` for a Blender-rendered PNG sequence plus governed audio/video mux. The latter is still real 3D motion; FFmpeg only packages Blender's frames. +4. Use either the `animation` or `cinematic` pipeline. A 3D world is a reusable production capability, not a separate pipeline. +5. Lock a fidelity tier. `blockout` is only for semantic layout and camera iteration. Hero/reference-led output requires `production`, licensed catalogs for repeated assets, Atlas/fal for unique assets when useful, and `blender_world` for scene assembly/rendering. + +## Artifact mapping + +| Stage | Contract | +|---|---| +| proposal | Record world promise, explicit-vs-inferred policy, fidelity tier, and either the browser-native Three.js/HyperFrames route or production Blender/FFmpeg route. | +| script | Use beats or sparse titles; narration is optional. | +| scene_plan | Define global, regional, and walk-level camera beats in one continuous coordinate system. | +| assets | Install/inventory repeated assets with `threejs_asset_catalog`; sample unique hero assets with `atlas_3d` or `fal_3d`; assemble and render global/regional/walk stills with `blender_world`; register meshes as `type="3d_asset"` and the editable world spec/`.blend` as `type="3d_world"`. | +| assets review | Produce semantic/wireframe diagnostics and representative snapshots; log bounded refinement issues. | +| edit | Carry camera times without changing region IDs or seed. | +| compose | Browser-native: call `video_compose` on the authored HyperFrames workspace. Production Blender: render a numbered PNG sequence with resume enabled, then let `video_compose`/FFmpeg package frames and audio without pretending FFmpeg generated the motion. | + +## Required asset metadata + +Record: + +- `source_tool: "threejs_world"`; +- `provider: "threejs"`; +- `quality_tier`, `seed`, and the returned model identifier; +- catalog IDs, source URLs, licenses, archive hashes, selected model IDs, and PBR material maps; +- workspace path and `world.json` path; +- region, landmark, instance, and terrain-triangle counts; +- diagnostic warnings and refinement rounds; +- `layer3_skills_read: ["threejs-world-generation"]`. + +## Review focus + +- The terrain is continuous and establishes the large-scale silhouette. +- Region color, relief, scatter, and landmarks agree with one semantic layout. +- Environmental instances respect slope and contact constraints. +- Global, regional, and walk-level frames remain spatially coherent. +- The camera never tunnels through terrain or clips the far plane. +- The final render preserves editability: world, regions, landmarks, and camera keys remain structured files. +- Production frames use textured authored models at foreground, midground, and background depth; dominant primitives or untextured flat ground are asset-gate failures. + +Do not use this path for isolated product spins, a CSS parallax landscape, or an AI-generated fly-through with no explicit scene graph. diff --git a/skills/meta/animation-runtime-selector.md b/skills/meta/animation-runtime-selector.md index 8c656672..43592e6c 100644 --- a/skills/meta/animation-runtime-selector.md +++ b/skills/meta/animation-runtime-selector.md @@ -62,6 +62,8 @@ when both were available is a CRITICAL reviewer finding. | Kinetic typography, HTML/GSAP-native motion, product promo, launch reel | **hyperframes** | `skills/core/hyperframes.md` + `.agents/skills/hyperframes/SKILL.md` (router) → `hyperframes-core` (contract), `hyperframes-creative` (palette/type), `hyperframes-animation` (motion) | | Website → video, UI-driven composition | **hyperframes** | `.agents/skills/website-to-video/SKILL.md` (renamed from website-to-hyperframes in 0.7) | | Registry block needed (data-chart, grain-overlay, shader transitions, etc.) | **hyperframes** | `.agents/skills/hyperframes-registry/SKILL.md` | +| Editable browser-native 3D terrain/world and free-viewpoint fly-through | **hyperframes** | `skills/creative/3d-world-generation.md` + `.agents/skills/threejs-world-generation/SKILL.md` | +| Reference-grade 3D world film rendered in Blender | **ffmpeg packaging of Blender frames** | `skills/creative/3d-world-generation.md` + `.agents/skills/3d-asset-generation/SKILL.md` | | Beat-synced music video (audio drives scene timing) | **hyperframes** | `.agents/skills/music-to-video/SKILL.md` — uses `hyperframes beats` to detect drops, lays out frames on the beat grid | | Porting an existing Remotion composition to HyperFrames | **hyperframes** | `.agents/skills/remotion-to-hyperframes/SKILL.md` — migration guidance, ONLY for explicit port requests | | BGM / SFX / image / icon resolution (any pipeline, any runtime) | n/a | `.agents/skills/media-use/SKILL.md` — `resolve` verb against project cache + global cache + HeyGen catalog | @@ -94,6 +96,8 @@ decision matrix and the list of features that stay Remotion-only in Phase 1. | Data chart (bar/line/pie/KPI) | Remotion built-in chart components | `remotion-composer/SCENE_TYPES.md` | | HyperFrames composition — animation knowledge (rules, blueprints, transitions, runtime adapters) | HyperFrames + GSAP default | `.agents/skills/hyperframes-animation` (consolidated motion skill) + `.agents/skills/gsap-core`, `.agents/skills/gsap-timeline` | | HyperFrames composition structure (data-* timing, tracks, sub-compositions) | HyperFrames | `.agents/skills/hyperframes-core` | +| Explicit Three.js world (terrain, regions, landmarks, camera path) | HyperFrames + `threejs_world` | `.agents/skills/threejs-world-generation` | +| Detailed Blender world film (generated/catalog meshes, PBR, camera path) | Blender + FFmpeg packaging | `.agents/skills/3d-asset-generation` | | HyperFrames creative direction (palette, type, narration, beat planning) | HyperFrames | `.agents/skills/hyperframes-creative` | | HyperFrames audio/media (TTS, BGM, SFX, transcription, captions, bg-removal) | HyperFrames | `.agents/skills/hyperframes-media` | | HyperFrames composition CLI work (lint/validate/inspect/snapshot/benchmark/render/lambda) | HyperFrames CLI 0.7+ | `.agents/skills/hyperframes-cli` | diff --git a/skills/meta/bespoke-composition.md b/skills/meta/bespoke-composition.md index 84c8a9cf..0797ef9a 100644 --- a/skills/meta/bespoke-composition.md +++ b/skills/meta/bespoke-composition.md @@ -209,10 +209,11 @@ registry (`src/components`, `src/Explainer`, etc.), and warns if `art_direction` - Verify before render: `npx hyperframes lint . && npx hyperframes validate . && npx hyperframes snapshot . --at `. Snapshot is HF's native visual-spotcheck (contact-sheet of PNG frames at chosen timestamps) — use it the same way an atelier `final_review.visual_spotcheck` would. -- **Render**: `npx hyperframes render . --output renders/.mp4`. - > Known gap (F13): `hyperframes_compose.render` currently requires `edit_decisions.cuts[]` - > from the templated path. For hand-authored HF compositions it errors; call `npx` directly - > until the tool grows a bespoke branch. +- **Render**: call `video_compose` with `render_runtime: "hyperframes"`, + `composition_mode: "atelier"`, and the authored `workspace_path`. It routes to + `hyperframes_compose.render_existing`, which preserves `index.html` and runs + the unified check gate, strict render, and post-render review. Call `npx + hyperframes render` directly only while debugging the runtime outside a pipeline. ## Guardrails so this doesn't backfire diff --git a/skills/pipelines/animation/asset-director.md b/skills/pipelines/animation/asset-director.md index 59ab5c18..964a47d5 100644 --- a/skills/pipelines/animation/asset-director.md +++ b/skills/pipelines/animation/asset-director.md @@ -31,7 +31,7 @@ Quick routing for common animation-pipeline needs: |-------|----------|---------| | Schema | `schemas/artifacts/asset_manifest.schema.json` | Artifact validation | | Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | Tool path and beat map | -| Tools | `tts_selector`, `image_selector`, `video_selector`, `math_animate`, `diagram_gen`, `code_snippet`, `music_gen` — selectors auto-discover all available providers from the registry | Asset production options | +| Tools | `tts_selector`, `image_selector`, `video_selector`, `math_animate`, `diagram_gen`, `code_snippet`, `threejs_world`, `music_gen` — selectors auto-discover all available providers from the registry | Asset production options | | Playbook | Active style playbook | Visual consistency | ## Process @@ -45,6 +45,13 @@ Prefer the lowest-variance useful path: - `math_animate` for real math motion, - provided artwork before new generation. +For a real 3D environment, read `skills/creative/3d-world-generation.md` and +the tool's `threejs-world-generation` Layer 3 skill, then use +`threejs_world` before any image or video generator. Build the cinematic +workspace plus a semantic or wireframe diagnostic pass. Register the editable +workspace as `type: "3d_world"`; snapshots belong in the assets review, while +the final MP4 belongs to compose. + ### 1b. Sample Preview (Prevents Wasted Spend) Before batch-generating assets, produce one sample of each expensive type and show the user: diff --git a/skills/pipelines/animation/compose-director.md b/skills/pipelines/animation/compose-director.md index ee953852..09fdbf06 100644 --- a/skills/pipelines/animation/compose-director.md +++ b/skills/pipelines/animation/compose-director.md @@ -11,8 +11,8 @@ Before any other work, read `edit_decisions.render_runtime`. It was locked at pr - **`render_runtime="hyperframes"`** — HTML/CSS/GSAP render. Do NOT follow the Remotion-specific sections below (public/ staging, Remotion composition JSON). Instead: 1. Read `skills/core/hyperframes.md` for the full routing model. 2. Read `.agents/skills/hyperframes/SKILL.md` and `.agents/skills/hyperframes-cli/SKILL.md` for authoring contract and CLI usage. - 3. Call `video_compose` with `edit_decisions.render_runtime="hyperframes"` — it delegates to `hyperframes_compose`, which owns workspace materialization under `projects//hyperframes/`, runs `hyperframes lint → validate → render`, and returns the MP4 path. - 4. `hyperframes lint` and `hyperframes validate` MUST both pass before render. Never skip validate; contrast can be deferred with `skip_contrast=true` during iteration but not for final delivery. + 3. Call `video_compose` with `edit_decisions.render_runtime="hyperframes"` — it delegates to `hyperframes_compose`, which owns workspace materialization under `projects//hyperframes/`, runs `hyperframes check → render`, and returns the MP4 path. + 4. `hyperframes check` MUST pass before render. It unifies lint, runtime, layout, motion, and WCAG contrast checks; contrast can be deferred with `skip_contrast=true` during iteration but not for final delivery. - **`render_runtime="ffmpeg"`** — simple concat/trim with no composition. Call `video_compose` directly; it will not auto-upgrade to Remotion. - **Runtime unavailable** — do NOT silently swap to a different engine. Surface the blocker to the user per AGENT_GUIDE.md > "Escalate Blockers Explicitly" and wait for approval (recorded as a `render_runtime_selection` decision in decision_log) before switching. diff --git a/skills/pipelines/animation/proposal-director.md b/skills/pipelines/animation/proposal-director.md index caffcc8c..e2df1455 100644 --- a/skills/pipelines/animation/proposal-director.md +++ b/skills/pipelines/animation/proposal-director.md @@ -31,6 +31,7 @@ Fit cheat-sheet for the recommendation (NOT an auto-decision): | Kinetic typography, product promo, launch reel, HTML/GSAP-native motion | HyperFrames | | Website-to-video or UI-driven composition | HyperFrames | | Registry blocks needed (data-chart, grain-overlay, shader transitions) | HyperFrames | +| Real 3D terrain, semantic regions, editable landmarks, free-viewpoint camera | HyperFrames + `threejs_world` | | Word-level/karaoke caption burn required | Remotion (HyperFrames caption parity deferred) | | Simple source-footage concat, no composition | ffmpeg | diff --git a/skills/pipelines/cinematic/asset-director.md b/skills/pipelines/cinematic/asset-director.md index 05a0105d..b1f0ddf5 100644 --- a/skills/pipelines/cinematic/asset-director.md +++ b/skills/pipelines/cinematic/asset-director.md @@ -32,6 +32,20 @@ Before authoring title cards, name plates, or SVG overlays, read **`skills/meta/ ## Process +### Explicit 3D-world path + +When the approved delivery promise is a continuous, free-viewpoint 3D world, +`threejs_world` satisfies semantic planning and browser-native motion: it creates +a real scene graph and time-driven camera, not a still-image fallback. Read +`skills/creative/3d-world-generation.md` and `.agents/skills/threejs-world-generation/SKILL.md`, +build into `projects//hyperframes/`, and review global, regional, walk, +semantic, and wireframe views before the assets gate. Keep +`render_runtime="hyperframes"` and `composition_mode="atelier"` locked for a +browser-native deliverable. For reference-grade video, lock Blender as the 3D +renderer and `render_runtime="ffmpeg"` solely as the image-sequence/audio packager. + +For hero/reference-driven work, `quality_tier="production"` is mandatory. Install licensed catalogs with `threejs_asset_catalog`, generate unique meshes with Atlas/fal when useful, assemble and render in Blender, and reject the asset gate if dominant primitives, flat untextured ground, low regional object density, or obvious repetition remain. `blockout` exists only for layout/camera approval. + ### 1. Prioritize Source Selects Start with: diff --git a/skills/pipelines/cinematic/compose-director.md b/skills/pipelines/cinematic/compose-director.md index 10049b24..9bf349a8 100644 --- a/skills/pipelines/cinematic/compose-director.md +++ b/skills/pipelines/cinematic/compose-director.md @@ -9,9 +9,13 @@ Render the cinematic piece with careful attention to grade, audio dynamics, and Read `edit_decisions.render_runtime`. Cinematic work routes to: - **`render_runtime="remotion"`** — default for video-led trailers using `CinematicRenderer`. Keeps video clips, transitions, and ambient overlays in one React-based pass. -- **`render_runtime="hyperframes"`** — for kinetic title cards, HTML/GSAP-driven trailers, or launch-reel-style compositions where the visual grammar is HTML/CSS. See `skills/core/hyperframes.md`. `hyperframes lint` and `hyperframes validate` must both pass before render. +- **`render_runtime="hyperframes"`** — for kinetic title cards, HTML/GSAP-driven trailers, launch-reel-style compositions, or explicit Three.js world fly-throughs. See `skills/core/hyperframes.md`. `hyperframes check` must pass before render. - **`render_runtime="ffmpeg"`** — simple source-footage concat with no composition. +For a Blender world film, FFmpeg is the approved packager for the numbered +Blender image sequence and audio. It must not synthesize camera motion or replace +missing Blender frames with pan/zoom effects. + `delivery_promise.motion_required=true` means the locked runtime is a commitment. Silent swap to another runtime (including FFmpeg Ken Burns) is a CRITICAL governance violation. If the locked runtime fails, escalate per AGENT_GUIDE.md > "Escalate Blockers Explicitly." **Pass `proposal_packet` to `video_compose.execute()`** so the tool's `runtime_swap_detected` check compares directly against `proposal_packet.production_plan.render_runtime`. Without it the swap check is skipped in-tool and only the reviewer skill catches the drift. diff --git a/skills/pipelines/cinematic/proposal-director.md b/skills/pipelines/cinematic/proposal-director.md index 13d291e1..096e7cc8 100644 --- a/skills/pipelines/cinematic/proposal-director.md +++ b/skills/pipelines/cinematic/proposal-director.md @@ -29,6 +29,12 @@ Fit cheat-sheet for the recommendation (NOT an auto-decision): **Motion-required deliverables**: if `delivery_promise.motion_required=true`, the chosen runtime is a commitment. Silent downgrade to FFmpeg Ken Burns or still-led animatic is forbidden. If the chosen runtime becomes unavailable at render time, compose must escalate, not substitute. +For an explicit 3D-world promise, query `3d_world_generation`. When +`threejs_world` and HyperFrames are available, this is a real motion path even +if cloud video generation is unavailable: it authors a continuous editable +scene graph with a deterministic camera. Record the tool, local $0 generation +cost, HyperFrames runtime, and atelier mode in the proposal. + A `render_runtime_selection` decision with only one option considered when both were available is a CRITICAL reviewer finding. ## Prerequisites diff --git a/tests/tools/test_3d_asset_generation.py b/tests/tools/test_3d_asset_generation.py new file mode 100644 index 00000000..604b741d --- /dev/null +++ b/tests/tools/test_3d_asset_generation.py @@ -0,0 +1,143 @@ +"""Contracts for cloud mesh generation and Blender world rendering.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema + +from tools.base_tool import ToolStatus +from tools.graphics import atlas_3d, fal_3d +from tools.graphics.atlas_3d import Atlas3D +from tools.graphics.blender_world import BlenderWorld, first_missing_frame +from tools.graphics.fal_3d import Fal3D +from tools.tool_registry import ToolRegistry + + +class _Response: + def __init__(self, payload=None, content=b""): + self._payload = payload + self.content = content + + def json(self): + return self._payload + + def raise_for_status(self): + return None + + +def test_registry_discovers_separate_3d_capabilities(): + registry = ToolRegistry() + registry.discover("tools") + assert {tool.name for tool in registry.get_by_capability("3d_asset_generation")} >= { + "atlas_3d", "fal_3d" + } + assert {tool.name for tool in registry.get_by_capability("3d_world_rendering")} >= { + "blender_world" + } + + +def test_atlas_cost_matrix_and_missing_key(monkeypatch, tmp_path): + for key in ("ATLASCLOUD_API_KEY", "ATLAS_CLOUD_API_KEY", "ATLAS_API_KEY"): + monkeypatch.delenv(key, raising=False) + tool = Atlas3D() + assert tool.get_status() == ToolStatus.UNAVAILABLE + assert tool.estimate_cost({"texture": False}) == 0.22 + assert tool.estimate_cost({"texture": True, "texture_quality": "standard"}) == 0.33 + assert tool.estimate_cost({"texture": True, "texture_quality": "detailed", "geometry_quality": "detailed"}) == 0.66 + result = tool.execute({"prompt": "a cottage", "output_path": str(tmp_path / "cottage.glb")}) + assert not result.success + assert "key" in (result.error or "").lower() + + +def test_fal_cost_matrix_and_input_validation(monkeypatch, tmp_path): + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.delenv("FAL_AI_API_KEY", raising=False) + tool = Fal3D() + assert tool.estimate_cost({"operation": "reconstruct_objects"}) == 0.02 + assert tool.estimate_cost({"operation": "image_to_3d", "enable_pbr": False}) == 0.225 + assert tool.estimate_cost({"operation": "image_to_3d", "enable_pbr": True}) == 0.375 + result = tool.execute({"operation": "text_to_3d", "output_path": str(tmp_path / "asset.glb")}) + assert not result.success + + +def test_blender_doctor_uses_verified_portable_runtime(): + result = BlenderWorld().execute({"operation": "doctor"}) + assert result.success, result.error + assert result.data["version_line"].startswith("OPENMONTAGE_BLENDER=4.5.10") + + +def test_blender_resume_finds_first_missing_contiguous_frame(tmp_path): + prefix = tmp_path / "frame-" + for frame in (1, 2, 4): + (tmp_path / f"frame-{frame:04d}.png").write_bytes(b"png") + assert first_missing_frame(prefix, 1, 5) == 3 + (tmp_path / "frame-0003.png").write_bytes(b"png") + assert first_missing_frame(prefix, 1, 4) is None + + +def test_asset_manifest_accepts_generated_mesh_type(): + schema = json.loads(Path("schemas/artifacts/asset_manifest.schema.json").read_text(encoding="utf-8")) + jsonschema.validate({ + "version": "1.0", + "assets": [{ + "id": "hero-cottage", + "type": "3d_asset", + "path": "assets/3d/hero-cottage.glb", + "source_tool": "atlas_3d", + "scene_id": "village", + }], + }, schema) + + +def test_atlas_success_downloads_glb_and_provenance(monkeypatch, tmp_path): + monkeypatch.setenv("ATLASCLOUD_API_KEY", "test-key") + monkeypatch.setattr(atlas_3d.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(atlas_3d.requests, "post", lambda *args, **kwargs: _Response({"data": {"id": "pred-1"}})) + + def fake_get(url, **_kwargs): + if "prediction/pred-1" in url: + return _Response({"data": {"status": "completed", "files": [{ + "type": "glb", "url": "https://cdn.example/asset.glb", + }]}}) + return _Response(content=b"glb-bytes") + + monkeypatch.setattr(atlas_3d.requests, "get", fake_get) + output = tmp_path / "asset.glb" + result = Atlas3D().execute({"prompt": "a weathered cottage", "output_path": str(output)}) + assert result.success, result.error + assert output.read_bytes() == b"glb-bytes" + provenance = json.loads(output.with_suffix(".provenance.json").read_text(encoding="utf-8")) + assert provenance["prediction_id"] == "pred-1" + assert provenance["model"] == "tripo-h3.1/text-to-3d" + + +def test_fal_success_downloads_glb_and_provenance(monkeypatch, tmp_path): + monkeypatch.setenv("FAL_KEY", "test-key") + monkeypatch.setattr(fal_3d.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(fal_3d.requests, "post", lambda *args, **kwargs: _Response({ + "request_id": "req-1", + "status_url": "https://queue.example/status", + "response_url": "https://queue.example/result", + })) + + def fake_get(url, **_kwargs): + if url.endswith("/status"): + return _Response({"status": "COMPLETED"}) + if url.endswith("/result"): + return _Response({"model_urls": {"glb": { + "url": "https://cdn.example/asset.glb", "content_type": "model/gltf-binary", + }}}) + return _Response(content=b"fal-glb") + + monkeypatch.setattr(fal_3d.requests, "get", fake_get) + output = tmp_path / "fal-asset.glb" + result = Fal3D().execute({ + "operation": "text_to_3d", "prompt": "a stone bridge", "output_path": str(output), + }) + assert result.success, result.error + assert output.read_bytes() == b"fal-glb" + provenance = json.loads(output.with_suffix(".provenance.json").read_text(encoding="utf-8")) + assert provenance["request_id"] == "req-1" + assert provenance["provider"] == "fal" diff --git a/tests/tools/test_threejs_asset_catalog.py b/tests/tools/test_threejs_asset_catalog.py new file mode 100644 index 00000000..f68c1d22 --- /dev/null +++ b/tests/tools/test_threejs_asset_catalog.py @@ -0,0 +1,45 @@ +import json +import zipfile +from pathlib import Path + +from tools.graphics.threejs_asset_catalog import CATALOGS, ThreeJSAssetCatalog + + +def test_catalog_list_is_rights_explicit(): + result = ThreeJSAssetCatalog().execute({"operation": "list"}) + assert result.success + assert result.data["catalogs"] + assert all(item["license"] == "CC0-1.0" for item in result.data["catalogs"].values()) + + +def test_catalog_install_inventories_gltf(tmp_path, monkeypatch): + source_zip = tmp_path / "fixture.zip" + with zipfile.ZipFile(source_zip, "w") as package: + package.writestr("Models/GLTF format/Tree.gltf", json.dumps({"asset": {"version": "2.0"}})) + package.writestr("Models/GLTF format/Tree.bin", b"mesh") + package.writestr("Textures/tree.png", b"texture") + + fixture_id = "fixture-catalog" + monkeypatch.setitem(CATALOGS, fixture_id, { + "title": "Fixture", + "source_url": "https://example.test/source", + "download_url": "https://example.test/catalog.zip", + "license": "CC0-1.0", + "license_url": "https://creativecommons.org/publicdomain/zero/1.0/", + "tags": ["fixture"], + }) + + def fake_download(_url: str, destination: Path) -> None: + destination.write_bytes(source_zip.read_bytes()) + + monkeypatch.setattr("tools.graphics.threejs_asset_catalog._download", fake_download) + output = tmp_path / "installed" + result = ThreeJSAssetCatalog().execute({ + "operation": "install", + "catalog_id": fixture_id, + "output_path": str(output), + }) + assert result.success, result.error + assert result.data["model_count"] == 1 + assert result.data["texture_count"] == 1 + assert (output / "catalog-manifest.json").exists() diff --git a/tests/tools/test_threejs_world.py b/tests/tools/test_threejs_world.py new file mode 100644 index 00000000..47815bf6 --- /dev/null +++ b/tests/tools/test_threejs_world.py @@ -0,0 +1,243 @@ +"""Contracts for semantic Three.js world generation and atelier rendering.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +from tools.base_tool import ToolResult +from tools.graphics.threejs_world import ThreeJSWorld +from tools.tool_registry import ToolRegistry +from tools.video.hyperframes_compose import HyperFramesCompose +from tools.video.video_compose import VideoCompose + + +def _world_spec(duration: float = 12.0) -> dict: + return { + "version": "1.0", + "title": "The Luminous Divide", + "seed": 260805248, + "explicit_constraints": ["one continuous explorable world"], + "inferred_details": ["cyan emissive accents provide visual continuity"], + "world": { + "size": 96, + "resolution": 72, + "elevation_scale": 12, + "water_level": -1.5, + }, + "regions": [ + { + "id": "wetlands", + "center": [-0.45, 0.15], + "radius": 0.9, + "landform": "basin", + "color": "#204a43", + "accent_color": "#71f7c4", + "scatter": {"tree": 18, "rock": 8, "crystal": 6}, + }, + { + "id": "rift", + "center": [0.5, -0.1], + "radius": 0.9, + "landform": "canyon", + "color": "#503040", + "accent_color": "#ff765f", + "scatter": {"tree": 0, "rock": 18, "crystal": 9}, + }, + ], + "landmarks": [ + { + "id": "threshold-ring", + "type": "ring", + "region_id": "wetlands", + "position": [-22, 0, 8], + "scale": 3.5, + } + ], + "camera_path": [ + {"time": 0, "position": [-42, 23, 38], "target": [-18, 0, 4]}, + {"time": duration / 2, "position": [0, 16, 24], "target": [10, 0, -4]}, + {"time": duration, "position": [42, 25, -34], "target": [20, 0, -5]}, + ], + } + + +def test_threejs_world_contract_and_registry_discovery(): + tool = ThreeJSWorld() + assert tool.capability == "3d_world_generation" + assert tool.provider == "threejs" + assert "threejs-world-generation" in tool.agent_skills + assert {"cinematic", "semantic", "wireframe"} == set( + tool.input_schema["properties"]["render_mode"]["enum"] + ) + + registry = ToolRegistry() + registry.discover("tools") + assert "threejs_world" in { + discovered.name + for discovered in registry.get_by_capability("3d_world_generation") + } + + +def test_threejs_world_validate_emits_worldclaw_diagnostics(): + result = ThreeJSWorld().execute( + {"operation": "validate", "world_spec": _world_spec(), "duration_seconds": 12} + ) + assert result.success, result.error + report = result.data["report"] + assert report["valid"] is True + assert report["stats"]["region_count"] == 2 + assert report["stats"]["terrain_triangles"] > 0 + assert set(report["diagnostic_passes"]) == {"cinematic", "semantic", "wireframe"} + assert report["review_views"] == [ + "global", + "regional", + "walk", + "semantic", + "wireframe", + ] + + +def test_threejs_world_build_is_deterministic_and_editable(tmp_path): + workspaces = [tmp_path / "first", tmp_path / "second"] + hashes = [] + for workspace in workspaces: + result = ThreeJSWorld().execute( + { + "operation": "build", + "world_spec": _world_spec(), + "output_path": str(workspace), + "duration_seconds": 12, + "width": 1280, + "height": 720, + "render_mode": "semantic", + } + ) + assert result.success, result.error + for filename in ( + "index.html", + "world.css", + "world-runtime.js", + "world.json", + "world-spec.js", + "world-report.json", + "hyperframes.json", + ): + assert (workspace / filename).is_file() + index = (workspace / "index.html").read_text(encoding="utf-8") + assert "--world-width: 1280px" in index + assert 'data-render-mode="semantic"' in index + hashes.append( + hashlib.sha256((workspace / "world-spec.js").read_bytes()).hexdigest() + ) + assert hashes[0] == hashes[1] + assert json.loads((workspaces[0] / "world.json").read_text(encoding="utf-8"))[ + "seed" + ] == 260805248 + + +def test_threejs_world_rejects_incomplete_camera_path(): + spec = _world_spec() + spec["camera_path"][-1]["time"] = 11 + result = ThreeJSWorld().execute( + {"operation": "validate", "world_spec": spec, "duration_seconds": 12} + ) + assert not result.success + assert "Last camera key" in (result.error or "") + + +def test_production_tier_rejects_primitive_only_spec(): + result = ThreeJSWorld().execute({ + "operation": "validate", + "world_spec": _world_spec(), + "duration_seconds": 12, + "quality_tier": "production", + "asset_catalog_paths": [], + }) + assert not result.success + assert "asset catalog" in (result.error or "").lower() + assert "asset-palette" in (result.error or "").lower() + assert "terrain material" in (result.error or "").lower() + + +def test_blockout_tier_is_labeled_as_nonproduction(): + result = ThreeJSWorld().execute({ + "operation": "validate", + "world_spec": _world_spec(), + "duration_seconds": 12, + "quality_tier": "blockout", + }) + assert result.success + assert result.data["report"]["quality_tier"] == "blockout" + assert any("do not present" in warning.lower() for warning in result.data["report"]["warnings"]) + + +def test_hyperframes_render_existing_preserves_authored_entry(tmp_path, monkeypatch): + workspace = tmp_path / "world" + workspace.mkdir() + entry = workspace / "index.html" + entry.write_text("
    ", encoding="utf-8") + tool = HyperFramesCompose() + monkeypatch.setattr(tool, "_runtime_check", lambda: {"runtime_available": True}) + monkeypatch.setattr(tool, "_check", lambda inputs: ToolResult(success=True, data={"ok": True})) + + def fake_run(args, *, cwd, timeout, check): + output = Path(args[args.index("--output") + 1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"rendered") + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(tool, "_run_hf", fake_run) + output = tmp_path / "renders" / "final.mp4" + result = tool.execute( + { + "operation": "render_existing", + "workspace_path": str(workspace), + "output_path": str(output), + "quality": "draft", + } + ) + assert result.success, result.error + assert result.data["authored_entry_preserved"] is True + assert entry.read_text(encoding="utf-8") == "
    " + assert output.is_file() + + +def test_video_compose_routes_empty_cut_atelier_to_existing_workspace(tmp_path, monkeypatch): + captured = {} + output = tmp_path / "final.mp4" + + def fake_hyperframes_execute(self, inputs): + captured.update(inputs) + Path(inputs["output_path"]).write_bytes(b"fake mp4") + return ToolResult(success=True, data={"output": inputs["output_path"]}) + + monkeypatch.setattr(VideoCompose, "_hyperframes_available", lambda self: True) + monkeypatch.setattr(HyperFramesCompose, "execute", fake_hyperframes_execute) + monkeypatch.setattr( + VideoCompose, + "_run_final_review", + lambda self, *args, **kwargs: {"status": "pass", "issues_found": []}, + ) + + result = VideoCompose().execute( + { + "operation": "render", + "workspace_path": str(tmp_path / "world"), + "output_path": str(output), + "edit_decisions": { + "version": "1.0", + "cuts": [], + "render_runtime": "hyperframes", + "renderer_family": "bespoke", + "composition_mode": "atelier", + "bespoke": {"entry": "index.html"}, + }, + } + ) + assert result.success, result.error + assert captured["operation"] == "render_existing" + assert captured["asset_manifest"] == {"version": "1.0", "assets": []} + assert captured["edit_decisions"]["cuts"] == [] diff --git a/tools/graphics/atlas_3d.py b/tools/graphics/atlas_3d.py new file mode 100644 index 00000000..8178058c --- /dev/null +++ b/tools/graphics/atlas_3d.py @@ -0,0 +1,227 @@ +"""Text-to-3D asset generation through Atlas Cloud. + +The tool deliberately exposes mesh generation as its own capability. Atlas's +HTTP endpoint happens to be named ``generateImage`` for historical reasons; +that implementation detail must not make 3D assets look like image outputs to +the OpenMontage registry or pipeline. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import requests + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +_MODEL = "tripo-h3.1/text-to-3d" +_ENV_KEYS = ("ATLASCLOUD_API_KEY", "ATLAS_CLOUD_API_KEY", "ATLAS_API_KEY") + + +def _api_key() -> str | None: + return next((os.environ.get(name) for name in _ENV_KEYS if os.environ.get(name)), None) + + +def _extension(url: str, content_type: str | None, fallback: str = ".glb") -> str: + suffix = Path(urlparse(url).path).suffix.lower() + if suffix in {".glb", ".gltf", ".fbx", ".obj", ".zip"}: + return suffix + if content_type == "model/gltf-binary": + return ".glb" + return fallback + + +class Atlas3D(BaseTool): + name = "atlas_3d" + version = "0.1.0" + tier = ToolTier.GENERATE + capability = "3d_asset_generation" + provider = "atlas_cloud" + stability = ToolStability.BETA + execution_mode = ExecutionMode.ASYNC + determinism = Determinism.SEEDED + runtime = ToolRuntime.API + dependencies = ["env:ATLASCLOUD_API_KEY"] + install_instructions = ( + "Set ATLASCLOUD_API_KEY (ATLAS_CLOUD_API_KEY and ATLAS_API_KEY are also accepted). " + "Create a key at https://www.atlascloud.ai/." + ) + agent_skills = ["3d-asset-generation", "threejs-loaders", "threejs-materials"] + capabilities = ["text_to_3d", "textured_glb", "pbr_mesh", "seeded_mesh_generation"] + supports = { + "text_to_3d": True, + "texture": True, + "pbr": True, + "detailed_geometry": True, + "face_limit": True, + "seed": True, + "glb": True, + } + best_for = [ + "Unique hero props and environment pieces described in text", + "Textured PBR GLB assets for Blender or Three.js", + ] + not_good_for = [ + "Whole coherent worlds in one request", + "Repeated foliage or rocks that should come from a licensed local catalog", + ] + input_schema = { + "type": "object", + "required": ["prompt", "output_path"], + "properties": { + "prompt": {"type": "string", "minLength": 3, "maxLength": 1024}, + "negative_prompt": {"type": "string"}, + "output_path": {"type": "string"}, + "texture": {"type": "boolean", "default": True}, + "pbr": {"type": "boolean", "default": True}, + "texture_quality": {"type": "string", "enum": ["standard", "detailed"], "default": "standard"}, + "geometry_quality": {"type": "string", "enum": ["standard", "detailed"], "default": "standard"}, + "face_limit": {"type": "integer", "minimum": 1000, "maximum": 2000000}, + "model_seed": {"type": "integer"}, + "image_seed": {"type": "integer"}, + "texture_seed": {"type": "integer"}, + "auto_size": {"type": "boolean", "default": True}, + "quad": {"type": "boolean", "default": False}, + "poll_timeout_seconds": {"type": "integer", "minimum": 30, "maximum": 1800, "default": 900}, + }, + } + output_schema = {"type": "object"} + artifact_schema = {"artifact": "3d_asset"} + resource_profile = ResourceProfile(cpu_cores=1, ram_mb=512, disk_mb=1000, network_required=True) + retry_policy = RetryPolicy(max_retries=1, retryable_errors=["rate_limit", "timeout"]) + idempotency_key_fields = [ + "prompt", "negative_prompt", "texture", "pbr", "texture_quality", + "geometry_quality", "face_limit", "model_seed", "image_seed", "texture_seed", + ] + side_effects = ["calls the Atlas Cloud API", "writes a generated mesh and provenance manifest"] + user_visible_verification = [ + "Inspect the downloaded mesh from front, back, silhouette, UV, and PBR material views before scene assembly" + ] + quality_score = 0.86 + + def get_status(self) -> ToolStatus: + return ToolStatus.AVAILABLE if _api_key() else ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + texture = bool(inputs.get("texture", True)) + texture_quality = inputs.get("texture_quality", "standard") + cost = 0.22 if not texture else (0.44 if texture_quality == "detailed" else 0.33) + if inputs.get("geometry_quality", "standard") == "detailed": + cost += 0.22 + if inputs.get("quad", False): + cost += 0.055 + return round(cost, 3) + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + key = _api_key() + if not key: + return ToolResult(success=False, error="Atlas Cloud API key not set. " + self.install_instructions) + + output = Path(str(inputs["output_path"])).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + payload: dict[str, Any] = { + "model": _MODEL, + "prompt": inputs["prompt"], + "texture": bool(inputs.get("texture", True)), + "pbr": bool(inputs.get("pbr", True)), + "texture_quality": inputs.get("texture_quality", "standard"), + "geometry_quality": inputs.get("geometry_quality", "standard"), + "auto_size": bool(inputs.get("auto_size", True)), + "quad": bool(inputs.get("quad", False)), + } + for key_name in ("negative_prompt", "face_limit", "model_seed", "image_seed", "texture_seed"): + if inputs.get(key_name) is not None: + payload[key_name] = inputs[key_name] + + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + started = time.time() + try: + submit = requests.post( + "https://api.atlascloud.ai/api/v1/model/generateImage", + headers=headers, + json=payload, + timeout=45, + ) + submit.raise_for_status() + prediction = submit.json()["data"] + prediction_id = prediction["id"] + deadline = time.monotonic() + int(inputs.get("poll_timeout_seconds", 900)) + while time.monotonic() < deadline: + poll = requests.get( + f"https://api.atlascloud.ai/api/v1/model/prediction/{prediction_id}", + headers=headers, + timeout=30, + ) + poll.raise_for_status() + prediction = poll.json().get("data", poll.json()) + status = str(prediction.get("status", "")).lower() + if status in {"completed", "succeeded"}: + break + if status in {"failed", "cancelled"}: + raise RuntimeError(str(prediction.get("error") or f"prediction {status}")) + time.sleep(3) + else: + raise TimeoutError(f"Prediction {prediction_id} exceeded the poll timeout") + + files = list(prediction.get("files") or []) + mesh_file = next( + (item for item in files if str(item.get("type", "")).lower() == "glb"), + None, + ) or next( + (item for item in files if _extension(str(item.get("url", "")), item.get("content_type")) == ".glb"), + None, + ) + if mesh_file is None: + outputs = [url for url in prediction.get("outputs") or [] if isinstance(url, str)] + mesh_url = next((url for url in outputs if Path(urlparse(url).path).suffix.lower() == ".glb"), None) + if mesh_url is None: + raise RuntimeError("Atlas prediction completed without a GLB output") + mesh_file = {"url": mesh_url, "content_type": "model/gltf-binary"} + + mesh_url = str(mesh_file["url"]) + if output.suffix.lower() != ".glb": + output = output.with_suffix(_extension(mesh_url, mesh_file.get("content_type"))) + download = requests.get(mesh_url, timeout=180) + download.raise_for_status() + output.write_bytes(download.content) + + manifest = output.with_suffix(".provenance.json") + manifest.write_text(json.dumps({ + "version": "1.0", + "provider": "atlas_cloud", + "model": _MODEL, + "prediction_id": prediction_id, + "prompt": inputs["prompt"], + "parameters": {key: value for key, value in payload.items() if key != "prompt"}, + "source_url": "https://www.atlascloud.ai/models/tripo-h3.1/text-to-3d", + "output": str(output), + }, indent=2), encoding="utf-8") + except Exception as exc: + return ToolResult(success=False, error=f"Atlas Cloud 3D generation failed: {exc}") + + return ToolResult( + success=True, + data={"provider": "atlas_cloud", "model": _MODEL, "output": str(output), "prediction_id": prediction_id}, + artifacts=[str(output), str(manifest)], + cost_usd=self.estimate_cost(inputs), + duration_seconds=round(time.time() - started, 2), + seed=inputs.get("model_seed"), + model=_MODEL, + ) diff --git a/tools/graphics/blender_world.py b/tools/graphics/blender_world.py new file mode 100644 index 00000000..a3b930a6 --- /dev/null +++ b/tools/graphics/blender_world.py @@ -0,0 +1,241 @@ +"""Deterministic Blender assembly and rendering for production 3D worlds.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PORTABLE_BLENDER = ( + _REPO_ROOT / ".runtime" / "blender" / "blender-4.5.10-windows-x64" / "blender.exe" +) +_RUNTIME_SCRIPT = Path(__file__).resolve().parent / "templates" / "blender-world-runtime.py" + + +def find_blender() -> Path | None: + configured = os.environ.get("BLENDER_PATH") + if configured and Path(configured).is_file(): + return Path(configured).resolve() + if _PORTABLE_BLENDER.is_file(): + return _PORTABLE_BLENDER.resolve() + discovered = shutil.which("blender") + return Path(discovered).resolve() if discovered else None + + +def first_missing_frame(output_prefix: str | Path, start_frame: int, end_frame: int) -> int | None: + """Return the first missing PNG in a contiguous Blender image sequence.""" + prefix = Path(output_prefix).expanduser().resolve() + for frame in range(start_frame, end_frame + 1): + candidate = prefix.parent / f"{prefix.name}{frame:04d}.png" + if not candidate.is_file(): + return frame + return None + + +class BlenderWorld(BaseTool): + name = "blender_world" + version = "0.3.0" + tier = ToolTier.GENERATE + capability = "3d_world_rendering" + provider = "blender" + stability = ToolStability.BETA + execution_mode = ExecutionMode.SYNC + determinism = Determinism.SEEDED + runtime = ToolRuntime.LOCAL_GPU + dependencies: list[str] = [] + install_instructions = ( + "Install Blender 4.5 LTS, set BLENDER_PATH, or place the portable runtime at " + ".runtime/blender/blender-4.5.10-windows-x64/blender.exe." + ) + agent_skills = [ + "3d-asset-generation", "threejs-world-generation", "threejs-loaders", "threejs-materials", + "threejs-textures", "threejs-lighting", "threejs-postprocessing", + ] + capabilities = [ + "gltf_glb_scene_assembly", "procedural_terrain", "linked_asset_scatter", + "pbr_materials", "eevee_next_render", "camera_flythrough", "blend_project_export", + "asset_unit_normalization", "bounding_box_ground_contact", "semantic_scatter_exclusions", + "terrain_following_ribbons", "visibility_windows", "title_safe_final_hold", + ] + supports = { + "glb": True, + "gltf": True, + "pbr": True, + "linked_instances": True, + "still": True, + "animation": True, + "transparent_background": True, + } + best_for = [ + "Production-quality world assembly from many generated and licensed assets", + "Dense terrain, lighting, material, camera, and contact-shadow work", + "Rendering a final image sequence for governed video composition", + ] + not_good_for = ["Interactive browser delivery", "Text-to-mesh generation"] + input_schema = { + "type": "object", + "required": ["operation"], + "properties": { + "operation": {"type": "string", "enum": ["doctor", "build", "render_still", "render_animation"]}, + "world_spec": {"type": "object"}, + "output_path": {"type": "string"}, + "blend_path": {"type": "string"}, + "width": {"type": "integer", "minimum": 320, "maximum": 7680, "default": 1920}, + "height": {"type": "integer", "minimum": 240, "maximum": 4320, "default": 1080}, + "samples": {"type": "integer", "minimum": 1, "maximum": 256, "default": 32}, + "fps": {"type": "integer", "minimum": 1, "maximum": 120, "default": 30}, + "duration_seconds": {"type": "number", "minimum": 1, "maximum": 600, "default": 60}, + "start_frame": {"type": "integer", "minimum": 1}, + "end_frame": {"type": "integer", "minimum": 1}, + "frame": {"type": "integer", "minimum": 1}, + "resume": {"type": "boolean", "default": False}, + }, + } + output_schema = {"type": "object"} + artifact_schema = {"artifact": "3d_world"} + resource_profile = ResourceProfile(cpu_cores=8, ram_mb=8192, vram_mb=6000, disk_mb=20000) + idempotency_key_fields = ["operation", "world_spec", "width", "height", "samples", "fps", "duration_seconds"] + side_effects = ["writes a .blend project", "may render an image or PNG sequence"] + user_visible_verification = [ + "Review global, regional, and walk-height stills before an animation render", + "Check imported mesh scale, ground contact, texture color space, shadowing, and camera clearance", + ] + quality_score = 0.94 + + def get_status(self) -> ToolStatus: + return ToolStatus.AVAILABLE if find_blender() and _RUNTIME_SCRIPT.is_file() else ToolStatus.UNAVAILABLE + + def estimate_runtime(self, inputs: dict[str, Any]) -> float: + if inputs.get("operation") == "render_animation": + return float(inputs.get("duration_seconds", 60)) * float(inputs.get("fps", 30)) * 2.0 + return 30.0 + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + blender = find_blender() + if not blender: + return ToolResult(success=False, error="Blender not found. " + self.install_instructions) + operation = str(inputs.get("operation") or "") + if operation == "doctor": + process = subprocess.run( + [str(blender), "--background", "--python-expr", "import bpy; print('OPENMONTAGE_BLENDER=' + bpy.app.version_string)"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, + ) + ok = process.returncode == 0 and "OPENMONTAGE_BLENDER=" in process.stdout + return ToolResult( + success=ok, + data={"blender_path": str(blender), "version_line": next((line for line in process.stdout.splitlines() if line.startswith("OPENMONTAGE_BLENDER=")), "")}, + error=None if ok else (process.stderr or process.stdout)[-1000:], + model="blender-4.5-lts", + ) + + if operation not in {"build", "render_still", "render_animation"}: + return ToolResult(success=False, error=f"Unknown operation: {operation}") + if not isinstance(inputs.get("world_spec"), dict): + return ToolResult(success=False, error="world_spec is required") + output_raw = inputs.get("output_path") + if operation != "build" and not output_raw: + return ToolResult(success=False, error="output_path is required for rendering") + blend_raw = inputs.get("blend_path") or ( + str(Path(str(output_raw)).with_suffix(".blend")) if output_raw else "blender-world.blend" + ) + blend_path = Path(str(blend_raw)).expanduser().resolve() + blend_path.parent.mkdir(parents=True, exist_ok=True) + spec_path = blend_path.with_suffix(".world.json") + spec_path.write_text(json.dumps(inputs["world_spec"], indent=2), encoding="utf-8") + + command = [ + str(blender), "--background", "--python", str(_RUNTIME_SCRIPT), "--", + "--operation", operation, + "--spec", str(spec_path), + "--blend", str(blend_path), + "--width", str(int(inputs.get("width", 1920))), + "--height", str(int(inputs.get("height", 1080))), + "--samples", str(int(inputs.get("samples", 32))), + "--fps", str(int(inputs.get("fps", 30))), + "--duration", str(float(inputs.get("duration_seconds", 60))), + ] + requested_start = int(inputs.get("start_frame", 1)) + requested_end = int(inputs.get("end_frame") or round( + float(inputs.get("duration_seconds", 60)) * int(inputs.get("fps", 30)) + )) + effective_start = requested_start + if operation == "render_animation" and inputs.get("resume"): + if not output_raw: + return ToolResult(success=False, error="output_path is required to resume a render") + missing = first_missing_frame(output_raw, requested_start, requested_end) + if missing is None: + return ToolResult( + success=True, + data={ + "blender_path": str(blender), + "blend_path": str(blend_path), + "output": str(output_raw), + "already_complete": True, + "start_frame": requested_start, + "end_frame": requested_end, + }, + model="blender-4.5-lts-eevee-next", + ) + effective_start = missing + if inputs.get("start_frame") is not None or operation == "render_animation": + command.extend(["--start-frame", str(effective_start)]) + if inputs.get("end_frame") is not None or operation == "render_animation": + command.extend(["--end-frame", str(requested_end)]) + if inputs.get("frame") is not None: + command.extend(["--frame", str(int(inputs["frame"]))]) + if output_raw: + command.extend(["--output", str(Path(str(output_raw)).expanduser().resolve())]) + + started = time.time() + try: + process = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=max(120, int(self.estimate_runtime(inputs) * 2.5)), + ) + except Exception as exc: + return ToolResult(success=False, error=f"Blender invocation failed: {exc}") + if process.returncode != 0: + return ToolResult(success=False, error="Blender world build failed: " + (process.stderr or process.stdout)[-3000:]) + + artifacts = [str(spec_path), str(blend_path)] + if output_raw: + output = Path(str(output_raw)).expanduser().resolve() + if output.exists(): + artifacts.append(str(output)) + report_path = blend_path.with_suffix(".report.json") + if report_path.exists(): + artifacts.append(str(report_path)) + return ToolResult( + success=True, + data={ + "blender_path": str(blender), + "blend_path": str(blend_path), + "output": str(output_raw or ""), + "report": str(report_path), + "start_frame": effective_start if operation == "render_animation" else None, + "end_frame": requested_end if operation == "render_animation" else None, + "resumed": bool(operation == "render_animation" and inputs.get("resume") and effective_start > requested_start), + }, + artifacts=artifacts, + duration_seconds=round(time.time() - started, 2), + seed=inputs["world_spec"].get("seed"), + model="blender-4.5-lts-eevee-next", + ) diff --git a/tools/graphics/fal_3d.py b/tools/graphics/fal_3d.py new file mode 100644 index 00000000..854aa328 --- /dev/null +++ b/tools/graphics/fal_3d.py @@ -0,0 +1,213 @@ +"""Text/image-to-3D and object reconstruction through fal.ai.""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import requests + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +_MODELS = { + "text_to_3d": "fal-ai/hunyuan-3d/v3.1/rapid/text-to-3d", + "image_to_3d": "fal-ai/hunyuan-3d/v3.1/rapid/image-to-3d", + "reconstruct_objects": "fal-ai/sam-3/3d-objects", +} + + +def _api_key() -> str | None: + return os.environ.get("FAL_KEY") or os.environ.get("FAL_AI_API_KEY") + + +def _download_file(file_info: dict[str, Any], destination: Path) -> Path: + url = str(file_info["url"]) + suffix = Path(urlparse(url).path).suffix.lower() + if suffix not in {".glb", ".gltf", ".obj", ".fbx", ".ply", ".zip"}: + suffix = ".glb" if file_info.get("content_type") == "model/gltf-binary" else destination.suffix + target = destination.with_suffix(suffix or ".glb") + response = requests.get(url, timeout=180) + response.raise_for_status() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(response.content) + return target + + +class Fal3D(BaseTool): + name = "fal_3d" + version = "0.1.0" + tier = ToolTier.GENERATE + capability = "3d_asset_generation" + provider = "fal" + stability = ToolStability.BETA + execution_mode = ExecutionMode.ASYNC + determinism = Determinism.SEEDED + runtime = ToolRuntime.API + dependencies = ["env:FAL_KEY"] + install_instructions = "Set FAL_KEY (or FAL_AI_API_KEY). Create a key at https://fal.ai/dashboard/keys." + agent_skills = ["3d-asset-generation", "threejs-loaders", "threejs-materials"] + capabilities = ["text_to_3d", "image_to_3d", "multi_object_reconstruction", "textured_glb", "pbr_mesh"] + supports = { + "text_to_3d": True, + "image_to_3d": True, + "multi_object": True, + "pbr": True, + "glb": True, + "seed": True, + } + best_for = [ + "Image-conditioned hero props whose silhouette must match concept art", + "Extracting multiple textured GLBs and placements from a regional concept image", + "Rapid textured environment assets", + ] + not_good_for = ["Rendering a complete cinematic world", "Large repeated scatter libraries"] + input_schema = { + "type": "object", + "required": ["operation", "output_path"], + "properties": { + "operation": {"type": "string", "enum": list(_MODELS)}, + "prompt": {"type": "string"}, + "image_url": {"type": "string"}, + "image_path": {"type": "string"}, + "output_path": {"type": "string"}, + "enable_pbr": {"type": "boolean", "default": True}, + "seed": {"type": "integer"}, + "export_textured_glb": {"type": "boolean", "default": True}, + "detection_threshold": {"type": "number", "minimum": 0.1, "maximum": 1.0}, + "poll_timeout_seconds": {"type": "integer", "minimum": 30, "maximum": 1800, "default": 900}, + }, + } + output_schema = {"type": "object"} + artifact_schema = {"artifact": "3d_asset"} + resource_profile = ResourceProfile(cpu_cores=1, ram_mb=512, disk_mb=2000, network_required=True) + retry_policy = RetryPolicy(max_retries=1, retryable_errors=["rate_limit", "timeout"]) + idempotency_key_fields = ["operation", "prompt", "image_url", "image_path", "enable_pbr", "seed"] + side_effects = ["calls fal.ai", "may upload a local input image", "writes generated 3D assets and provenance"] + user_visible_verification = ["Inspect silhouette, back-side completion, topology, texture seams, and material response"] + quality_score = 0.88 + + def get_status(self) -> ToolStatus: + return ToolStatus.AVAILABLE if _api_key() else ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + if inputs.get("operation") == "reconstruct_objects": + return 0.02 + return 0.225 + (0.15 if inputs.get("enable_pbr", True) else 0.0) + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + key = _api_key() + if not key: + return ToolResult(success=False, error="fal.ai API key not set. " + self.install_instructions) + operation = str(inputs.get("operation") or "") + if operation not in _MODELS: + return ToolResult(success=False, error=f"Unknown operation {operation!r}") + if operation == "text_to_3d" and not inputs.get("prompt"): + return ToolResult(success=False, error="prompt is required for text_to_3d") + if operation != "text_to_3d" and not (inputs.get("image_url") or inputs.get("image_path")): + return ToolResult(success=False, error=f"image_url or image_path is required for {operation}") + + payload: dict[str, Any] = {} + if operation == "text_to_3d": + payload["prompt"] = inputs["prompt"] + payload["enable_pbr"] = bool(inputs.get("enable_pbr", True)) + else: + image_url = inputs.get("image_url") + if not image_url: + from tools.video._shared import upload_image_fal + image_url = upload_image_fal(str(inputs["image_path"])) + payload["image_url" if operation == "reconstruct_objects" else "input_image_url"] = image_url + if operation == "image_to_3d": + payload["enable_pbr"] = bool(inputs.get("enable_pbr", True)) + else: + payload["export_textured_glb"] = bool(inputs.get("export_textured_glb", True)) + if inputs.get("prompt"): + payload["prompt"] = inputs["prompt"] + if inputs.get("detection_threshold") is not None: + payload["detection_threshold"] = inputs["detection_threshold"] + if inputs.get("seed") is not None: + payload["seed"] = inputs["seed"] + + headers = {"Authorization": f"Key {key}", "Content-Type": "application/json"} + model = _MODELS[operation] + started = time.time() + try: + submit = requests.post(f"https://queue.fal.run/{model}", headers=headers, json=payload, timeout=45) + submit.raise_for_status() + queued = submit.json() + status_url = queued["status_url"] + response_url = queued["response_url"] + deadline = time.monotonic() + int(inputs.get("poll_timeout_seconds", 900)) + while time.monotonic() < deadline: + status_response = requests.get(status_url, headers=headers, timeout=30) + status_response.raise_for_status() + status = str(status_response.json().get("status", "")).upper() + if status == "COMPLETED": + break + if status in {"FAILED", "CANCELLED"}: + raise RuntimeError(f"request {status.lower()}") + time.sleep(3) + else: + raise TimeoutError("fal.ai request exceeded the poll timeout") + result_response = requests.get(response_url, headers=headers, timeout=45) + result_response.raise_for_status() + data = result_response.json() + + destination = Path(str(inputs["output_path"])).expanduser().resolve() + file_infos: list[dict[str, Any]] = [] + if operation == "reconstruct_objects": + if data.get("model_glb"): + file_infos.append(data["model_glb"]) + file_infos.extend(data.get("individual_glbs") or []) + else: + urls = data.get("model_urls") or {} + candidate = urls.get("glb") or data.get("model_glb") or urls.get("obj") + if candidate: + file_infos.append(candidate) + if not file_infos: + raise RuntimeError("fal.ai completed without a downloadable mesh") + + artifacts: list[str] = [] + for index, file_info in enumerate(file_infos): + target = destination if index == 0 else destination.with_name(f"{destination.stem}-{index:02d}{destination.suffix}") + artifacts.append(str(_download_file(file_info, target))) + provenance = destination.with_suffix(".provenance.json") + provenance.write_text(json.dumps({ + "version": "1.0", + "provider": "fal", + "model": model, + "request_id": queued.get("request_id"), + "operation": operation, + "prompt": inputs.get("prompt"), + "metadata": data.get("metadata"), + "source_url": f"https://fal.ai/models/{model}", + "outputs": artifacts, + }, indent=2), encoding="utf-8") + artifacts.append(str(provenance)) + except Exception as exc: + return ToolResult(success=False, error=f"fal.ai 3D generation failed: {exc}") + + return ToolResult( + success=True, + data={"provider": "fal", "model": model, "operation": operation, "outputs": artifacts[:-1]}, + artifacts=artifacts, + cost_usd=self.estimate_cost(inputs), + duration_seconds=round(time.time() - started, 2), + seed=inputs.get("seed"), + model=model, + ) diff --git a/tools/graphics/templates/blender-world-runtime.py b/tools/graphics/templates/blender-world-runtime.py new file mode 100644 index 00000000..429908fa --- /dev/null +++ b/tools/graphics/templates/blender-world-runtime.py @@ -0,0 +1,434 @@ +"""Blender-side deterministic world builder used by ``blender_world``. + +No creative decisions live here: palette, density, asset choices, regions, +paths, water, camera, and lighting arrive in the JSON world specification. +""" + +from __future__ import annotations + +import argparse +import json +import math +import random +import sys +from pathlib import Path + +import bpy +from mathutils import Vector +from mathutils.noise import fractal, hetero_terrain, noise_vector, seed_set + + +def args_after_separator() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--operation", required=True) + parser.add_argument("--spec", required=True) + parser.add_argument("--blend", required=True) + parser.add_argument("--output", default="") + parser.add_argument("--width", type=int, default=1920) + parser.add_argument("--height", type=int, default=1080) + parser.add_argument("--samples", type=int, default=32) + parser.add_argument("--fps", type=int, default=30) + parser.add_argument("--duration", type=float, default=60.0) + parser.add_argument("--start-frame", type=int) + parser.add_argument("--end-frame", type=int) + parser.add_argument("--frame", type=int) + return parser.parse_args(sys.argv[sys.argv.index("--") + 1 :]) + + +def material(name: str, color: list[float], roughness: float = 0.7, metallic: float = 0.0, emission: float = 0.0): + mat = bpy.data.materials.new(name) + mat.diffuse_color = (*color[:3], color[3] if len(color) > 3 else 1.0) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + bsdf.inputs["Base Color"].default_value = mat.diffuse_color + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = metallic + if emission: + bsdf.inputs["Emission Color"].default_value = mat.diffuse_color + bsdf.inputs["Emission Strength"].default_value = emission + return mat + + +def terrain_height(x: float, y: float, spec: dict) -> float: + terrain = spec.get("terrain", {}) + scale = float(terrain.get("height_scale", 16.0)) + frequency = float(terrain.get("frequency", 0.018)) + base = hetero_terrain(Vector((x * frequency, y * frequency, 0)), 1.0, 2.0, 5.0, 0.7) + detail = fractal(Vector((x * frequency * 4.2, y * frequency * 4.2, 4.3)), 1.1, 2.0, 3.0) + height = (base - 0.65) * scale + detail * scale * 0.12 + for region in spec.get("regions", []): + cx, cy = region.get("center", [0, 0])[:2] + radius = max(1.0, float(region.get("radius", 40))) + distance = math.hypot(x - cx, y - cy) + influence = max(0.0, 1.0 - distance / radius) + influence = influence * influence * (3.0 - 2.0 * influence) + height += float(region.get("height_offset", 0)) * influence + if region.get("flatten") is not None: + target = float(region["flatten"]) + strength = float(region.get("flatten_strength", 0.75)) * influence + height = height * (1.0 - strength) + target * strength + return height + + +def clear_scene(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for block in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights): + for item in list(block): + if item.users == 0: + block.remove(item) + + +def build_terrain(spec: dict): + terrain = spec.get("terrain", {}) + size = float(terrain.get("size", 240)) + resolution = max(32, min(320, int(terrain.get("resolution", 180)))) + vertices = [] + faces = [] + for iy in range(resolution): + y = -size / 2 + size * iy / (resolution - 1) + for ix in range(resolution): + x = -size / 2 + size * ix / (resolution - 1) + vertices.append((x, y, terrain_height(x, y, spec))) + for iy in range(resolution - 1): + for ix in range(resolution - 1): + a = iy * resolution + ix + faces.append((a, a + 1, a + resolution + 1, a + resolution)) + mesh = bpy.data.meshes.new("WorldTerrainMesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new("WorldTerrain", mesh) + bpy.context.collection.objects.link(obj) + palette = terrain.get("palette", [[0.11, 0.28, 0.08, 1], [0.28, 0.45, 0.10, 1], [0.35, 0.28, 0.16, 1]]) + mats = [material(f"Terrain-{index}", list(color), 0.92) for index, color in enumerate(palette)] + region_material_start = len(mats) + for region in spec.get("regions", []): + color = region.get("color") + if color: + mats.append(material(f"Region-{region.get('id', len(mats))}", list(color), float(region.get("roughness", 0.88)))) + for mat in mats: + obj.data.materials.append(mat) + for polygon in mesh.polygons: + center = sum((mesh.vertices[index].co for index in polygon.vertices), Vector()) / len(polygon.vertices) + z = center.z + normal_z = polygon.normal.z + region_choice = None + region_strength = 0.0 + for region_index, region in enumerate(spec.get("regions", [])): + if not region.get("color"): + continue + cx, cy = region.get("center", [0, 0])[:2] + radius = max(1.0, float(region.get("radius", 40))) + strength = max(0.0, 1.0 - math.hypot(center.x - cx, center.y - cy) / radius) + if strength > region_strength: + region_choice = region_index + region_strength = strength + if normal_z < 0.67: + polygon.material_index = min(2, len(palette) - 1) + elif region_choice is not None and region_strength > 0.18: + polygon.material_index = region_material_start + region_choice + else: + polygon.material_index = 1 if z > 4.0 and len(palette) > 1 else 0 + bevel = obj.modifiers.new("Terrain micro bevel", "BEVEL") + bevel.width = 0.18 + bevel.segments = 2 + return obj + + +def make_ribbon(name: str, points: list[list[float]], width: float, mat, z_offset: float = 0.25): + """Build a flat terrain-following ribbon, avoiding tube-like curve bevels.""" + vertices = [] + faces = [] + half_width = width / 2.0 + for index, source in enumerate(points): + x, y = source[:2] + previous = points[max(0, index - 1)] + following = points[min(len(points) - 1, index + 1)] + dx = float(following[0]) - float(previous[0]) + dy = float(following[1]) - float(previous[1]) + length = max(0.001, math.hypot(dx, dy)) + nx, ny = -dy / length, dx / length + for side in (-1.0, 1.0): + vx, vy = x + nx * half_width * side, y + ny * half_width * side + vz = source[2] if len(source) > 2 else terrain_height(vx, vy, WORLD_SPEC) + z_offset + vertices.append((vx, vy, vz)) + if index: + base = index * 2 + faces.append((base - 2, base, base + 1, base - 1)) + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + obj.data.materials.append(mat) + bevel = obj.modifiers.new(f"{name} edge softness", "BEVEL") + bevel.width = min(0.22, width * 0.03) + bevel.segments = 2 + return obj + + +def import_asset_collection(path: Path, asset_id: str): + before = set(bpy.context.scene.objects) + if path.suffix.lower() in {".glb", ".gltf"}: + bpy.ops.import_scene.gltf(filepath=str(path)) + elif path.suffix.lower() == ".fbx": + bpy.ops.import_scene.fbx(filepath=str(path)) + elif path.suffix.lower() == ".obj": + bpy.ops.wm.obj_import(filepath=str(path)) + else: + raise ValueError(f"Unsupported asset format: {path}") + imported = [obj for obj in bpy.context.scene.objects if obj not in before] + collection = bpy.data.collections.new(f"ASSET::{asset_id}") + bpy.context.scene.collection.children.link(collection) + for obj in imported: + for owner in list(obj.users_collection): + owner.objects.unlink(obj) + collection.objects.link(obj) + if obj.type == "MESH": + for polygon in obj.data.polygons: + polygon.use_smooth = True + # Keep the source collection available for collection instances without + # rendering its authoring copy at the origin. + bpy.context.scene.collection.children.unlink(collection) + z_values = [] + for obj in imported: + if obj.type == "MESH": + z_values.extend((obj.matrix_world @ Vector(corner)).z for corner in obj.bound_box) + source_height = max(z_values) - min(z_values) if z_values else 1.0 + source_floor = min(z_values) if z_values else 0.0 + return collection, max(0.001, source_height), source_floor + + +def scatter_assets(spec: dict): + rng = random.Random(int(spec.get("seed", 1))) + report = {"asset_sources": 0, "instances": 0, "missing_assets": []} + for asset in spec.get("assets", []): + path = Path(asset["path"]).expanduser().resolve() + if not path.is_file(): + report["missing_assets"].append(str(path)) + continue + asset_id = str(asset.get("id") or path.stem) + collection, source_height, source_floor = import_asset_collection(path, asset_id) + target_height = float(asset.get("target_height", source_height)) + normalization = target_height / source_height + report["asset_sources"] += 1 + placements = list(asset.get("placements") or []) + if not placements: + center = asset.get("center", [0, 0]) + radius = float(asset.get("radius", 30)) + count = int(asset.get("count", 1)) + exclusions = list(asset.get("exclusion_zones") or []) + attempts = 0 + while len(placements) < count and attempts < count * 24: + attempts += 1 + angle = rng.random() * math.tau + distance = radius * math.sqrt(rng.random()) + x = center[0] + math.cos(angle) * distance + y = center[1] + math.sin(angle) * distance + if any( + math.hypot(x - zone.get("center", [0, 0])[0], y - zone.get("center", [0, 0])[1]) + < float(zone.get("radius", 0)) + for zone in exclusions + ): + continue + placements.append({ + "position": [x, y], + "rotation": rng.random() * math.tau, + "scale": rng.uniform(float(asset.get("scale_min", 1)), float(asset.get("scale_max", asset.get("scale_min", 1)))), + }) + for index, placement in enumerate(placements): + x, y = placement.get("position", [0, 0])[:2] + z = terrain_height(float(x), float(y), spec) + float(placement.get("z_offset", 0)) + instance = bpy.data.objects.new(f"{asset_id}-{index:03d}", None) + instance.instance_type = "COLLECTION" + instance.instance_collection = collection + instance.location = (x, y, z) + if placement.get("rotation_euler_degrees") is not None: + instance.rotation_euler = [math.radians(float(value)) for value in placement["rotation_euler_degrees"]] + else: + instance.rotation_euler[2] = float(placement.get("rotation", 0)) + scale = placement.get("scale", 1) + if isinstance(scale, list): + instance.scale = [component * normalization for component in scale] + instance.location.z = z - source_floor * instance.scale.z + else: + effective_scale = scale * normalization + instance.scale = (effective_scale, effective_scale, effective_scale) + instance.location.z = z - source_floor * effective_scale + visible_from = placement.get("visible_from_seconds", asset.get("visible_from_seconds")) + visible_until = placement.get("visible_until_seconds", asset.get("visible_until_seconds")) + if visible_from is not None: + reveal_frame = max(1, round(float(visible_from) * int(spec.get("fps", 30)))) + instance.hide_render = True + instance.hide_viewport = True + instance.keyframe_insert("hide_render", frame=max(1, reveal_frame - 1)) + instance.keyframe_insert("hide_viewport", frame=max(1, reveal_frame - 1)) + instance.hide_render = False + instance.hide_viewport = False + instance.keyframe_insert("hide_render", frame=reveal_frame) + instance.keyframe_insert("hide_viewport", frame=reveal_frame) + if visible_until is not None: + hide_frame = max(1, round(float(visible_until) * int(spec.get("fps", 30)))) + instance.hide_render = False + instance.hide_viewport = False + instance.keyframe_insert("hide_render", frame=hide_frame) + instance.keyframe_insert("hide_viewport", frame=hide_frame) + instance.hide_render = True + instance.hide_viewport = True + instance.keyframe_insert("hide_render", frame=hide_frame + 1) + instance.keyframe_insert("hide_viewport", frame=hide_frame + 1) + bpy.context.collection.objects.link(instance) + report["instances"] += 1 + return report + + +def look_at(obj, target): + obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler() + + +def setup_camera_and_lights(spec: dict, args: argparse.Namespace): + camera_spec = spec.get("camera", {}) + camera_data = bpy.data.cameras.new("HeroCamera") + camera = bpy.data.objects.new("HeroCamera", camera_data) + bpy.context.collection.objects.link(camera) + camera.location = camera_spec.get("position", [105, -125, 95]) + camera_data.lens = float(camera_spec.get("lens", 44)) + camera_data.sensor_width = 36 + target = bpy.data.objects.new("CameraTarget", None) + target.empty_display_type = "SPHERE" + target.empty_display_size = 1.0 + target.location = camera_spec.get("target", [0, 0, 3]) + bpy.context.collection.objects.link(target) + tracking = camera.constraints.new(type="TRACK_TO") + tracking.target = target + tracking.track_axis = "TRACK_NEGATIVE_Z" + tracking.up_axis = "UP_Y" + bpy.context.scene.camera = camera + + camera.data.lens = float(camera_spec.get("lens", 44)) + + for key_index, key in enumerate(camera_spec.get("path", [])): + frame = 1 + round(float(key["time"]) * args.fps) + camera.location = key["position"] + target.location = key["target"] + if key.get("lens") is not None: + camera.data.lens = float(key["lens"]) + camera.data.keyframe_insert("lens", frame=frame) + camera.keyframe_insert("location", frame=frame) + target.keyframe_insert("location", frame=frame) + for animated in (camera, target): + for curve in animated.animation_data.action.fcurves if animated.animation_data and animated.animation_data.action else []: + for point in curve.keyframe_points: + point.interpolation = "BEZIER" + + lighting = spec.get("lighting", {}) + sun_data = bpy.data.lights.new("Sun", "SUN") + sun_data.energy = float(lighting.get("sun_energy", 3.0)) + sun_data.angle = math.radians(float(lighting.get("sun_angle_degrees", 18))) + sun = bpy.data.objects.new("Sun", sun_data) + sun.rotation_euler = [math.radians(value) for value in lighting.get("sun_rotation_degrees", [35, -28, -32])] + bpy.context.collection.objects.link(sun) + + area_data = bpy.data.lights.new("SkyFill", "AREA") + area_data.energy = float(lighting.get("fill_energy", 850)) + area_data.shape = "DISK" + area_data.size = 70 + area = bpy.data.objects.new("SkyFill", area_data) + area.location = (-35, -20, 70) + look_at(area, [0, 0, 0]) + bpy.context.collection.objects.link(area) + + world = bpy.context.scene.world or bpy.data.worlds.new("World") + bpy.context.scene.world = world + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + background.inputs["Color"].default_value = lighting.get("world_color", [0.16, 0.24, 0.34, 1]) + background.inputs["Strength"].default_value = float(lighting.get("world_strength", 0.5)) + + +def setup_title(spec: dict, args: argparse.Namespace): + title = spec.get("title_card") + if not title: + return + curve = bpy.data.curves.new("FinalTitleText", "FONT") + curve.body = str(title.get("text", "")) + curve.align_x = "CENTER" + curve.align_y = "CENTER" + curve.size = float(title.get("size", 0.62)) + curve.extrude = 0.012 + curve.bevel_depth = 0.004 + text = bpy.data.objects.new("FinalTitle", curve) + bpy.context.collection.objects.link(text) + text.parent = bpy.context.scene.camera + text.location = title.get("camera_local_position", [0, -0.92, -5.2]) + text.rotation_euler = (0, 0, 0) + text.data.materials.append(material("FinalTitleGold", title.get("color", [0.95, 0.68, 0.24, 1]), 0.38, 0.05, 0.12)) + start_frame = round(float(title.get("start_seconds", 58.0)) * args.fps) + text.hide_render = True + text.hide_viewport = True + text.keyframe_insert("hide_render", frame=max(1, start_frame - 1)) + text.keyframe_insert("hide_viewport", frame=max(1, start_frame - 1)) + text.hide_render = False + text.hide_viewport = False + text.keyframe_insert("hide_render", frame=start_frame) + text.keyframe_insert("hide_viewport", frame=start_frame) + + +def setup_render(args: argparse.Namespace): + scene = bpy.context.scene + scene.render.engine = "BLENDER_EEVEE_NEXT" + scene.eevee.taa_render_samples = args.samples + scene.render.resolution_x = args.width + scene.render.resolution_y = args.height + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.film_transparent = False + scene.render.fps = args.fps + scene.frame_start = args.start_frame or 1 + scene.frame_end = args.end_frame or max(1, round(args.duration * args.fps)) + scene.render.image_settings.color_mode = "RGBA" + scene.view_settings.look = "AgX - Medium High Contrast" + scene.render.filepath = args.output + + +def build(spec: dict, args: argparse.Namespace): + global WORLD_SPEC + WORLD_SPEC = spec + clear_scene() + seed_set(int(spec.get("seed", 1))) + build_terrain(spec) + water = spec.get("water") + if water: + water_mat = material("Water", water.get("color", [0.03, 0.30, 0.48, 0.82]), 0.13, 0.05) + make_ribbon("River", water.get("points", [[-90, -20], [0, 0], [90, 25]]), float(water.get("width", 5)), water_mat, float(water.get("z_offset", 0.6))) + path_spec = spec.get("path") + if path_spec: + path_mat = material("Path", path_spec.get("color", [0.55, 0.36, 0.14, 1]), 0.95) + make_ribbon("Path", path_spec.get("points", []), float(path_spec.get("width", 2.2)), path_mat, float(path_spec.get("z_offset", 0.32))) + report = scatter_assets(spec) + setup_camera_and_lights(spec, args) + setup_title(spec, args) + setup_render(args) + bpy.ops.wm.save_as_mainfile(filepath=args.blend) + Path(args.blend).with_suffix(".report.json").write_text(json.dumps({ + "version": "1.0", "engine": "BLENDER_EEVEE_NEXT", "seed": spec.get("seed"), **report, + }, indent=2), encoding="utf-8") + return report + + +def main(): + args = args_after_separator() + spec = json.loads(Path(args.spec).read_text(encoding="utf-8")) + report = build(spec, args) + if args.operation == "render_still": + bpy.context.scene.frame_set(args.frame or 1) + bpy.context.scene.render.filepath = args.output + bpy.ops.render.render(write_still=True) + elif args.operation == "render_animation": + bpy.context.scene.render.filepath = args.output + bpy.ops.render.render(animation=True) + print("OPENMONTAGE_WORLD_REPORT=" + json.dumps(report, sort_keys=True)) + + +WORLD_SPEC = {} +main() diff --git a/tools/graphics/templates/threejs_world/index.html b/tools/graphics/templates/threejs_world/index.html new file mode 100644 index 00000000..838332fb --- /dev/null +++ b/tools/graphics/templates/threejs_world/index.html @@ -0,0 +1,66 @@ + + + + + + __TITLE__ + + + + +
    +
    + + + + +
    +
    OPENMONTAGE · EXPLICIT WORLD 01
    +

    __TITLE__

    +

    ONE CONTINUOUS WORLD · FREE VIEWPOINT · SEEDED & EDITABLE

    +
    + + + +
    BUILDING WORLD GRAPH…
    +
    +
    + + + + + + diff --git a/tools/graphics/templates/threejs_world/world-runtime.js b/tools/graphics/templates/threejs_world/world-runtime.js new file mode 100644 index 00000000..1b53e828 --- /dev/null +++ b/tools/graphics/templates/threejs_world/world-runtime.js @@ -0,0 +1,478 @@ +import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.181.2/+esm"; +import { GLTFLoader } from "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/loaders/GLTFLoader.js"; +import { WORLD_SPEC } from "./world-spec.js"; +import { ASSET_CATALOG } from "./asset-catalog.js"; + +const root = document.getElementById("world-root"); +const canvas = document.getElementById("world-canvas"); +const status = document.getElementById("world-status"); +const regionName = document.getElementById("world-region-name"); +const timecode = document.getElementById("world-timecode"); +const altitude = document.getElementById("world-altitude"); +const renderMode = root.dataset.renderMode || "cinematic"; +const width = Number(root.dataset.width || canvas.width || 1920); +const height = Number(root.dataset.height || canvas.height || 1080); +const qualityTier = window.__WORLD_QUALITY_TIER__ || "blockout"; + +function mulberry32(seed) { + let value = seed >>> 0; + return () => { + value += 0x6d2b79f5; + let t = value; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function hashString(text) { + let hash = 2166136261; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function clamp(value, low, high) { return Math.max(low, Math.min(high, value)); } +function smoothstep(value) { const t = clamp(value, 0, 1); return t * t * (3 - 2 * t); } + +function regionWeights(nx, nz) { + const raw = WORLD_SPEC.regions.map((region) => { + const dx = nx - region.center[0]; + const dz = nz - region.center[1]; + const distance = Math.hypot(dx, dz) / Math.max(0.05, region.radius); + const softness = Math.max(0.02, region.blend_width); + return Math.max(0.00001, Math.exp(-Math.pow(Math.max(0, distance - 0.05), 2) / (softness * 2.8))); + }); + const total = raw.reduce((sum, value) => sum + value, 0) || 1; + return raw.map((value) => value / total); +} + +function landform(kind, dx, dz, distance) { + if (kind === "peak") return Math.pow(Math.max(0, 1 - distance), 2.2); + if (kind === "ridge") return Math.max(0, 1 - Math.abs(dx * 1.8 + Math.sin(dz * 5) * 0.16)); + if (kind === "dune") return (Math.sin((dx + dz * 0.25) * 18) + 1) * 0.24; + if (kind === "terrace") return Math.floor(Math.max(0, 1 - distance) * 5) / 5; + if (kind === "basin") return -Math.pow(Math.max(0, 1 - distance), 1.7); + if (kind === "canyon") return -Math.pow(Math.max(0, 1 - Math.abs(dx + Math.sin(dz * 7) * 0.1)), 2); + return 0; +} + +function heightAt(x, z) { + const half = WORLD_SPEC.world.size / 2; + const nx = x / half; + const nz = z / half; + const weights = regionWeights(nx, nz); + const seed = WORLD_SPEC.seed * 0.01337; + let elevation = 0; + WORLD_SPEC.regions.forEach((region, index) => { + const frequency = region.frequency; + const noise = ( + Math.sin((nx * 3.1 + seed + index) * frequency * Math.PI) + + Math.cos((nz * 2.7 - seed * 0.7 + index) * frequency * Math.PI) + + 0.5 * Math.sin((nx + nz) * frequency * 7.3 + seed * 3 + index) + ) / 2.5; + const dx = nx - region.center[0]; + const dz = nz - region.center[1]; + const distance = Math.hypot(dx, dz) / Math.max(0.05, region.radius); + elevation += weights[index] * ( + region.base_elevation + region.amplitude * (noise * 0.48 + landform(region.landform, dx, dz, distance) * 0.8) + ); + }); + return elevation * WORLD_SPEC.world.elevation_scale; +} + +function dominantRegion(x, z) { + const half = WORLD_SPEC.world.size / 2; + const weights = regionWeights(x / half, z / half); + let index = 0; + for (let i = 1; i < weights.length; i += 1) if (weights[i] > weights[index]) index = i; + return { region: WORLD_SPEC.regions[index], weight: weights[index] }; +} + +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: "high-performance" }); +renderer.setSize(width, height, false); +renderer.setPixelRatio(1); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = renderMode === "cinematic" ? 1.05 : 1; +renderer.shadowMap.enabled = renderMode === "cinematic"; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; + +const scene = new THREE.Scene(); +scene.background = new THREE.Color(WORLD_SPEC.atmosphere.sky_color); +if (renderMode === "cinematic" && WORLD_SPEC.atmosphere.fog_density > 0) { + scene.fog = new THREE.FogExp2(WORLD_SPEC.atmosphere.fog_color, WORLD_SPEC.atmosphere.fog_density); +} + +const camera = new THREE.PerspectiveCamera(45, width / height, 0.2, WORLD_SPEC.world.size * 4); +const terrainGroup = new THREE.Group(); +terrainGroup.name = "terrain-foundation"; +const environmentGroup = new THREE.Group(); +environmentGroup.name = "environment-prototypes"; +const landmarkGroup = new THREE.Group(); +landmarkGroup.name = "regional-landmarks"; +scene.add(terrainGroup, environmentGroup, landmarkGroup); + +const hemi = new THREE.HemisphereLight( + WORLD_SPEC.atmosphere.sky_color, + WORLD_SPEC.atmosphere.ground_color, + renderMode === "cinematic" ? 1.45 : 2.2, +); +scene.add(hemi); + +const sun = new THREE.DirectionalLight(WORLD_SPEC.atmosphere.sun_color, WORLD_SPEC.atmosphere.sun_intensity); +sun.position.fromArray(WORLD_SPEC.atmosphere.sun_position); +sun.castShadow = renderMode === "cinematic"; +sun.shadow.mapSize.set(1024, 1024); +const shadowSpan = WORLD_SPEC.world.size * 0.62; +sun.shadow.camera.left = -shadowSpan; +sun.shadow.camera.right = shadowSpan; +sun.shadow.camera.top = shadowSpan; +sun.shadow.camera.bottom = -shadowSpan; +sun.shadow.camera.near = 1; +sun.shadow.camera.far = WORLD_SPEC.world.size * 3; +sun.shadow.bias = -0.0003; +sun.shadow.normalBias = 0.035; +scene.add(sun); + +const terrainGeometry = new THREE.PlaneGeometry( + WORLD_SPEC.world.size, + WORLD_SPEC.world.size, + WORLD_SPEC.world.resolution, + WORLD_SPEC.world.resolution, +); +terrainGeometry.rotateX(-Math.PI / 2); +const position = terrainGeometry.attributes.position; +const colors = new Float32Array(position.count * 3); +const color = new THREE.Color(); +const mixed = new THREE.Color(); +for (let index = 0; index < position.count; index += 1) { + const x = position.getX(index); + const z = position.getZ(index); + position.setY(index, heightAt(x, z)); + const weights = regionWeights(x / (WORLD_SPEC.world.size / 2), z / (WORLD_SPEC.world.size / 2)); + mixed.setRGB(0, 0, 0); + WORLD_SPEC.regions.forEach((region, regionIndex) => { + color.set(renderMode === "semantic" ? region.accent_color : region.color); + mixed.r += color.r * weights[regionIndex]; + mixed.g += color.g * weights[regionIndex]; + mixed.b += color.b * weights[regionIndex]; + }); + colors[index * 3] = mixed.r; + colors[index * 3 + 1] = mixed.g; + colors[index * 3 + 2] = mixed.b; +} +position.needsUpdate = true; +terrainGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); +terrainGeometry.computeVertexNormals(); +terrainGeometry.computeBoundingSphere(); + +const terrainMaterial = renderMode === "wireframe" + ? new THREE.MeshBasicMaterial({ color: 0x8de8ff, wireframe: true, transparent: true, opacity: 0.82 }) + : new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92, metalness: 0.02, flatShading: false }); +const terrain = new THREE.Mesh(terrainGeometry, terrainMaterial); +terrain.name = "semantic-terrain"; +terrain.receiveShadow = renderMode === "cinematic"; +terrainGroup.add(terrain); + +let water = null; +if (renderMode !== "wireframe") { + const waterGeometry = new THREE.PlaneGeometry(WORLD_SPEC.world.size * 1.08, WORLD_SPEC.world.size * 1.08, 1, 1); + waterGeometry.rotateX(-Math.PI / 2); + const waterMaterial = new THREE.MeshPhysicalMaterial({ + color: renderMode === "semantic" ? 0x1765a3 : 0x143d55, + roughness: 0.22, + metalness: 0.08, + transmission: renderMode === "cinematic" ? 0.22 : 0, + transparent: true, + opacity: renderMode === "cinematic" ? 0.76 : 0.9, + depthWrite: false, + }); + water = new THREE.Mesh(waterGeometry, waterMaterial); + water.name = "global-water-plane"; + water.position.y = WORLD_SPEC.world.water_level; + terrainGroup.add(water); +} + +const instanceStats = { tree: 0, rock: 0, crystal: 0 }; +function slopeAt(x, z) { + const step = 0.65; + return Math.abs(heightAt(x + step, z) - heightAt(x - step, z)) + + Math.abs(heightAt(x, z + step) - heightAt(x, z - step)); +} + +function scatterPoints(region, count, salt) { + const random = mulberry32((WORLD_SPEC.seed ^ hashString(region.id + salt)) >>> 0); + const points = []; + const half = WORLD_SPEC.world.size / 2; + for (let index = 0; index < count; index += 1) { + let accepted = null; + for (let attempt = 0; attempt < 14; attempt += 1) { + const angle = random() * Math.PI * 2; + const radius = Math.sqrt(random()) * region.radius * half; + const x = region.center[0] * half + Math.cos(angle) * radius; + const z = region.center[1] * half + Math.sin(angle) * radius; + const dominant = dominantRegion(x, z); + if (dominant.region.id !== region.id || dominant.weight < 0.34) continue; + if (slopeAt(x, z) > region.slope_limit) continue; + accepted = { x, z, y: heightAt(x, z), rotation: random() * Math.PI * 2, scale: 0.72 + random() * 0.72 }; + break; + } + if (accepted) points.push(accepted); + } + return points; +} + +function makeInstanced(geometry, material, points, transform) { + if (!points.length) return null; + const mesh = new THREE.InstancedMesh(geometry, material, points.length); + const dummy = new THREE.Object3D(); + points.forEach((point, index) => { + transform(dummy, point, index); + dummy.updateMatrix(); + mesh.setMatrixAt(index, dummy.matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + mesh.castShadow = renderMode === "cinematic"; + mesh.receiveShadow = renderMode === "cinematic"; + environmentGroup.add(mesh); + return mesh; +} + +const catalogModels = new Map(); +for (const catalog of ASSET_CATALOG.catalogs || []) { + for (const model of catalog.models || []) { + catalogModels.set(`${catalog.catalog_id}:${model.id}`, model.runtime_path); + } +} + +async function loadProductionPalette() { + if (qualityTier !== "production") return; + const loader = new GLTFLoader(); + const prototypes = new Map(); + const palette = WORLD_SPEC.asset_palette || []; + await Promise.all(palette.map(async (entry) => { + const key = `${entry.catalog_id}:${entry.model_id}`; + const path = catalogModels.get(key); + if (!path || prototypes.has(key)) return; + const gltf = await loader.loadAsync(path); + gltf.scene.traverse((node) => { + if (!node.isMesh) return; + node.castShadow = renderMode === "cinematic"; + node.receiveShadow = renderMode === "cinematic"; + if (node.material) node.material.envMapIntensity = 0.8; + }); + prototypes.set(key, gltf.scene); + })); + + palette.forEach((entry, entryIndex) => { + const prototype = prototypes.get(`${entry.catalog_id}:${entry.model_id}`); + if (!prototype) return; + const region = WORLD_SPEC.regions.find((item) => item.id === entry.region_id) || WORLD_SPEC.regions[entryIndex % WORLD_SPEC.regions.length]; + const points = scatterPoints(region, Math.min(180, Math.max(1, Number(entry.count || 12))), `catalog-${entry.id || entryIndex}`); + points.forEach((point, pointIndex) => { + const clone = prototype.clone(true); + const random = mulberry32((WORLD_SPEC.seed ^ hashString(`${entry.id || entryIndex}:${pointIndex}`)) >>> 0); + const scaleRange = Array.isArray(entry.scale_range) ? entry.scale_range : [0.8, 1.4]; + const scale = THREE.MathUtils.lerp(Number(scaleRange[0]), Number(scaleRange[1]), random()) * Number(entry.base_scale || 1); + clone.position.set(point.x, point.y + Number(entry.y_offset || 0), point.z); + clone.rotation.y = random() * Math.PI * 2; + clone.scale.setScalar(scale); + clone.name = `catalog-${entry.id || entryIndex}-${pointIndex}`; + environmentGroup.add(clone); + }); + }); +} + +WORLD_SPEC.regions.forEach((region) => { + const regionColor = new THREE.Color(renderMode === "semantic" ? region.accent_color : region.color); + const accentColor = new THREE.Color(region.accent_color); + + const rocks = scatterPoints(region, region.scatter.rock, "rock"); + instanceStats.rock += rocks.length; + makeInstanced( + new THREE.DodecahedronGeometry(0.72, 0), + new THREE.MeshStandardMaterial({ color: regionColor.clone().multiplyScalar(0.72), roughness: 0.95, wireframe: renderMode === "wireframe" }), + rocks, + (dummy, point) => { + dummy.position.set(point.x, point.y + 0.42 * point.scale, point.z); + dummy.rotation.set(point.rotation * 0.17, point.rotation, point.rotation * 0.11); + dummy.scale.set(point.scale * 1.1, point.scale * 0.72, point.scale); + }, + ); + + const crystals = scatterPoints(region, region.scatter.crystal, "crystal"); + instanceStats.crystal += crystals.length; + makeInstanced( + new THREE.OctahedronGeometry(0.72, 0), + new THREE.MeshStandardMaterial({ color: accentColor, emissive: accentColor, emissiveIntensity: renderMode === "cinematic" ? 1.7 : 0.25, roughness: 0.28, metalness: 0.28, wireframe: renderMode === "wireframe" }), + crystals, + (dummy, point) => { + dummy.position.set(point.x, point.y + 0.82 * point.scale, point.z); + dummy.rotation.set(0.08, point.rotation, 0.05); + dummy.scale.set(point.scale * 0.46, point.scale * 1.75, point.scale * 0.46); + }, + ); + + const trees = scatterPoints(region, region.scatter.tree, "tree"); + instanceStats.tree += trees.length; + const trunkMaterial = new THREE.MeshStandardMaterial({ color: renderMode === "semantic" ? region.accent_color : 0x3e2b22, roughness: 1, wireframe: renderMode === "wireframe" }); + const canopyMaterial = new THREE.MeshStandardMaterial({ color: regionColor.clone().offsetHSL(0, 0.08, 0.09), roughness: 0.94, wireframe: renderMode === "wireframe" }); + makeInstanced(new THREE.CylinderGeometry(0.16, 0.23, 1.75, 6), trunkMaterial, trees, (dummy, point) => { + dummy.position.set(point.x, point.y + 0.88 * point.scale, point.z); + dummy.rotation.set(0, point.rotation, 0); + dummy.scale.setScalar(point.scale); + }); + makeInstanced(new THREE.ConeGeometry(0.92, 2.4, 7), canopyMaterial, trees, (dummy, point) => { + dummy.position.set(point.x, point.y + 2.35 * point.scale, point.z); + dummy.rotation.set(0, point.rotation, 0); + dummy.scale.setScalar(point.scale); + }); +}); + +function materialPair(landmark) { + const base = new THREE.Color(renderMode === "semantic" ? landmark.accent_color : landmark.color); + const accent = new THREE.Color(landmark.accent_color); + return { + base: new THREE.MeshStandardMaterial({ color: base, roughness: 0.72, metalness: 0.18, wireframe: renderMode === "wireframe" }), + accent: new THREE.MeshStandardMaterial({ color: accent, emissive: accent, emissiveIntensity: renderMode === "cinematic" ? 1.25 : 0.2, roughness: 0.28, metalness: 0.38, wireframe: renderMode === "wireframe" }), + }; +} + +function addMesh(group, geometry, material, positionValue, scaleValue = [1, 1, 1], rotationValue = [0, 0, 0]) { + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(...positionValue); + mesh.scale.set(...scaleValue); + mesh.rotation.set(...rotationValue); + mesh.castShadow = renderMode === "cinematic"; + mesh.receiveShadow = renderMode === "cinematic"; + group.add(mesh); + return mesh; +} + +function buildLandmark(landmark) { + const group = new THREE.Group(); + group.name = landmark.id; + const materials = materialPair(landmark); + const s = landmark.scale; + const random = mulberry32((WORLD_SPEC.seed ^ hashString(landmark.id)) >>> 0); + + if (landmark.type === "arch") { + addMesh(group, new THREE.BoxGeometry(1, 1, 1), materials.base, [-0.72 * s, 0.7 * s, 0], [0.34 * s, 1.4 * s, 0.42 * s]); + addMesh(group, new THREE.BoxGeometry(1, 1, 1), materials.base, [0.72 * s, 0.7 * s, 0], [0.34 * s, 1.4 * s, 0.42 * s]); + addMesh(group, new THREE.BoxGeometry(1, 1, 1), materials.accent, [0, 1.48 * s, 0], [1.06 * s, 0.24 * s, 0.42 * s]); + } else if (landmark.type === "tower") { + addMesh(group, new THREE.CylinderGeometry(0.52, 0.68, 2.4, 8), materials.base, [0, 1.2 * s, 0], [s, s, s]); + addMesh(group, new THREE.TorusGeometry(0.68, 0.09, 8, 24), materials.accent, [0, 2.08 * s, 0], [s, s, s], [Math.PI / 2, 0, 0]); + addMesh(group, new THREE.ConeGeometry(0.72, 1.2, 8), materials.accent, [0, 2.72 * s, 0], [s, s, s]); + } else if (landmark.type === "ruin") { + for (let i = 0; i < 7; i += 1) { + const angle = (i / 7) * Math.PI * 2 + random() * 0.2; + const radius = s * (0.55 + random() * 0.45); + const h = s * (0.45 + random() * 1.1); + addMesh(group, new THREE.BoxGeometry(1, 1, 1), i === 3 ? materials.accent : materials.base, [Math.cos(angle) * radius, h / 2, Math.sin(angle) * radius], [s * 0.25, h, s * 0.25], [0, random() * Math.PI, (random() - 0.5) * 0.14]); + } + } else if (landmark.type === "crystal") { + for (let i = 0; i < 5; i += 1) { + const angle = (i / 5) * Math.PI * 2; + const localScale = s * (i === 0 ? 1.45 : 0.62 + random() * 0.35); + addMesh(group, new THREE.OctahedronGeometry(0.55, 0), materials.accent, [Math.cos(angle) * s * 0.42, localScale * 0.62, Math.sin(angle) * s * 0.42], [localScale * 0.44, localScale * 1.25, localScale * 0.44], [0.06, angle, 0.04]); + } + } else if (landmark.type === "settlement") { + for (let i = 0; i < 9; i += 1) { + const angle = (i / 9) * Math.PI * 2 + random() * 0.3; + const radius = s * (0.35 + random() * 1.05); + const h = s * (0.24 + random() * 0.52); + addMesh(group, new THREE.CylinderGeometry(0.42, 0.56, 1, 6), materials.base, [Math.cos(angle) * radius, h / 2, Math.sin(angle) * radius], [s * 0.42, h, s * 0.42], [0, angle, 0]); + addMesh(group, new THREE.ConeGeometry(0.64, 0.7, 6), materials.accent, [Math.cos(angle) * radius, h + s * 0.18, Math.sin(angle) * radius], [s * 0.42, s * 0.42, s * 0.42], [0, angle, 0]); + } + } else if (landmark.type === "ring") { + addMesh(group, new THREE.TorusGeometry(1, 0.12, 12, 64), materials.accent, [0, 1.25 * s, 0], [s, s, s], [0, 0, 0]); + addMesh(group, new THREE.CylinderGeometry(0.28, 0.48, 1.4, 8), materials.base, [0, 0.7 * s, 0], [s, s, s]); + } else { + addMesh(group, new THREE.BoxGeometry(1, 1, 1), materials.base, [0, 0.95 * s, 0], [0.62 * s, 1.9 * s, 0.62 * s], [0.04, 0.35, -0.03]); + addMesh(group, new THREE.OctahedronGeometry(0.32, 0), materials.accent, [0, 2.08 * s, 0], [s, s, s]); + } + + const terrainY = heightAt(landmark.position[0], landmark.position[2]); + group.position.set(landmark.position[0], terrainY + landmark.position[1], landmark.position[2]); + group.rotation.set(...landmark.rotation); + landmarkGroup.add(group); +} +if (qualityTier === "blockout") WORLD_SPEC.landmarks.forEach(buildLandmark); + +function interpolateVector(left, right, amount) { + return new THREE.Vector3( + THREE.MathUtils.lerp(left[0], right[0], amount), + THREE.MathUtils.lerp(left[1], right[1], amount), + THREE.MathUtils.lerp(left[2], right[2], amount), + ); +} + +function cameraAt(time) { + const keys = WORLD_SPEC.camera_path; + if (time <= keys[0].time) return { ...keys[0], positionV: new THREE.Vector3(...keys[0].position), targetV: new THREE.Vector3(...keys[0].target) }; + if (time >= keys[keys.length - 1].time) { + const key = keys[keys.length - 1]; + return { ...key, positionV: new THREE.Vector3(...key.position), targetV: new THREE.Vector3(...key.target) }; + } + for (let index = 0; index < keys.length - 1; index += 1) { + const left = keys[index]; + const right = keys[index + 1]; + if (time >= left.time && time <= right.time) { + const amount = smoothstep((time - left.time) / Math.max(0.0001, right.time - left.time)); + return { + label: amount < 0.5 ? left.label : right.label, + positionV: interpolateVector(left.position, right.position, amount), + targetV: interpolateVector(left.target, right.target, amount), + fov: THREE.MathUtils.lerp(left.fov, right.fov, amount), + }; + } + } + const fallback = keys[keys.length - 1]; + return { ...fallback, positionV: new THREE.Vector3(...fallback.position), targetV: new THREE.Vector3(...fallback.target) }; +} + +function formatTime(value) { + const minutes = Math.floor(value / 60).toString().padStart(2, "0"); + const seconds = (value % 60).toFixed(1).padStart(4, "0"); + return `${minutes}:${seconds}`; +} + +function renderAt(timeValue) { + const time = Math.max(0, Number(timeValue) || 0); + const state = cameraAt(time); + camera.position.copy(state.positionV); + camera.fov = state.fov; + camera.updateProjectionMatrix(); + camera.lookAt(state.targetV); + + if (water) water.material.opacity = (renderMode === "cinematic" ? 0.73 : 0.88) + Math.sin(time * 0.42) * 0.035; + sun.intensity = WORLD_SPEC.atmosphere.sun_intensity * (0.96 + Math.sin(time * 0.09) * 0.04); + + const regionState = dominantRegion(state.targetV.x, state.targetV.z); + regionName.textContent = state.label || regionState.region.label; + timecode.textContent = formatTime(time); + altitude.textContent = camera.position.y.toFixed(1).padStart(5, "0"); + renderer.render(scene, camera); +} + +async function finalizeWorld() { + await loadProductionPalette(); + window.addEventListener("hf-seek", (event) => renderAt(event.detail.time)); + window.__worldRenderAt = renderAt; + window.__worldGraph = { scene, camera, terrainGroup, environmentGroup, landmarkGroup, instanceStats }; + window.__worldReady = true; + status.textContent = `WORLD READY · ${WORLD_SPEC.regions.length} REGIONS · ${WORLD_SPEC.landmarks.length} LANDMARKS · ${qualityTier.toUpperCase()}`; + status.style.opacity = "0"; + renderAt(window.__hfThreeTime || 0); +} + +finalizeWorld().catch((error) => { + window.__worldReady = false; + window.__worldError = String(error?.stack || error); + status.textContent = "WORLD ASSET LOAD FAILED"; + console.error(error); +}); diff --git a/tools/graphics/templates/threejs_world/world.css b/tools/graphics/templates/threejs_world/world.css new file mode 100644 index 00000000..899d5b16 --- /dev/null +++ b/tools/graphics/templates/threejs_world/world.css @@ -0,0 +1,79 @@ +:root { + color-scheme: dark; + font-family: Inter, sans-serif; + background: #05080d; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #05080d; } + +#world-root { + position: relative; + width: var(--world-width, 1920px); + height: var(--world-height, 1080px); + overflow: hidden; + background: transparent; + color: #f5f8ff; +} + +.world-stage { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; background: #05080d; } +#world-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } + +#world-vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 50% 43%, transparent 42%, rgba(3, 6, 11, 0.28) 73%, rgba(1, 3, 7, 0.86) 100%), + linear-gradient(180deg, rgba(1, 5, 10, 0.05), rgba(1, 5, 10, 0.24)); +} + +#world-grain { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.09; + mix-blend-mode: soft-light; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.65'/%3E%3C/svg%3E"); +} + +.world-title-card { + position: absolute; + left: clamp(38px, 5vw, 96px); + bottom: clamp(48px, 9.6vh, 104px); + width: min(920px, calc(100% - clamp(76px, 10vw, 192px))); + opacity: 0; + text-shadow: 0 4px 36px rgba(0, 0, 0, 0.84); +} + +.eyebrow { margin-bottom: 18px; font: 600 18px/1.2 "JetBrains Mono", Consolas, monospace; letter-spacing: 0.22em; color: #9fdff2; } +.world-title-card h1 { margin: 0; max-width: 900px; font: 720 clamp(42px, 4.3vw, 82px)/0.94 Inter, sans-serif; letter-spacing: -0.055em; text-transform: uppercase; } +.world-title-card p { margin: 22px 0 0; font: 600 16px/1.4 "JetBrains Mono", Consolas, monospace; letter-spacing: 0.16em; color: rgba(236, 245, 255, 0.72); } + +.world-hud { + position: absolute; + top: clamp(32px, 6.5vh, 70px); + right: clamp(32px, 3.9vw, 74px); + width: min(330px, calc(100% - 64px)); + padding: 22px 24px 20px; + border: 1px solid rgba(170, 225, 244, 0.24); + border-radius: 2px; + background: linear-gradient(135deg, rgba(4, 12, 20, 0.72), rgba(6, 13, 20, 0.24)); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28), inset 0 0 24px rgba(111, 212, 243, 0.035); + backdrop-filter: blur(8px); + opacity: 0; +} + +.hud-rule { width: 54px; height: 3px; margin-bottom: 18px; background: #9fdff2; box-shadow: 0 0 16px rgba(159, 223, 242, 0.55); } +.hud-label { font: 600 12px/1.2 "JetBrains Mono", Consolas, monospace; letter-spacing: 0.2em; color: rgba(213, 238, 248, 0.52); } +.hud-value { margin-top: 7px; min-height: 54px; font: 680 28px/1.02 Inter, sans-serif; letter-spacing: -0.03em; text-transform: uppercase; } +.hud-grid { display: grid; grid-template-columns: 74px 1fr; gap: 9px 16px; padding-top: 17px; border-top: 1px solid rgba(172, 224, 241, 0.16); font: 500 12px/1.1 "JetBrains Mono", Consolas, monospace; letter-spacing: 0.1em; } +.hud-grid span { color: rgba(209, 236, 246, 0.68); } +.hud-grid strong { text-align: right; color: rgba(235, 249, 255, 0.88); text-transform: uppercase; } + +#world-status { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 13px 18px; border: 1px solid rgba(174, 232, 249, 0.32); background: rgba(3, 9, 15, 0.72); font: 600 13px/1 "JetBrains Mono", Consolas, monospace; letter-spacing: 0.15em; color: #cceefa; } + +[data-render-mode="semantic"] #world-vignette, +[data-render-mode="wireframe"] #world-vignette, +[data-render-mode="semantic"] #world-grain, +[data-render-mode="wireframe"] #world-grain { display: none; } diff --git a/tools/graphics/threejs_asset_catalog.py b/tools/graphics/threejs_asset_catalog.py new file mode 100644 index 00000000..9026f2ba --- /dev/null +++ b/tools/graphics/threejs_asset_catalog.py @@ -0,0 +1,171 @@ +"""Licensed local GLTF/PBR catalog ingestion for Three.js worlds. + +This module intentionally handles acquisition and provenance only. Creative +selection and placement remain agent decisions expressed through world_spec. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import urllib.request +import zipfile +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + ToolResult, + ToolRuntime, + ToolStability, + ToolTier, +) + + +CATALOGS: dict[str, dict[str, Any]] = { + "kenney-nature-kit": { + "title": "Kenney Nature Kit", + "source_url": "https://kenney.nl/assets/nature-kit", + "download_url": "https://kenney.nl/media/pages/assets/nature-kit/37ac38a37b-1677698939/kenney_nature-kit.zip", + "license": "CC0-1.0", + "license_url": "https://creativecommons.org/publicdomain/zero/1.0/", + "tags": ["nature", "tree", "rock", "foliage"], + }, + "kenney-fantasy-town-kit": { + "title": "Kenney Fantasy Town Kit 2.0", + "source_url": "https://kenney.nl/assets/fantasy-town-kit", + "download_url": "https://kenney.nl/media/pages/assets/fantasy-town-kit/efe948d309-1754222374/kenney_fantasy-town-kit_2.0.zip", + "license": "CC0-1.0", + "license_url": "https://creativecommons.org/publicdomain/zero/1.0/", + "tags": ["medieval", "village", "building", "wall", "prop"], + }, + "kenney-survival-kit": { + "title": "Kenney Survival Kit 2.0", + "source_url": "https://kenney.nl/assets/survival-kit", + "download_url": "https://kenney.nl/media/pages/assets/survival-kit/4065a8185b-1712149243/kenney_survival-kit.zip", + "license": "CC0-1.0", + "license_url": "https://creativecommons.org/publicdomain/zero/1.0/", + "tags": ["survival", "camp", "nature", "prop"], + }, +} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _download(url: str, destination: Path) -> None: + request = urllib.request.Request(url, headers={"User-Agent": "OpenMontage/threejs-asset-catalog"}) + with urllib.request.urlopen(request, timeout=120) as response, destination.open("wb") as output: + shutil.copyfileobj(response, output) + + +class ThreeJSAssetCatalog(BaseTool): + """Install inspectable, rights-safe world asset catalogs.""" + + name = "threejs_asset_catalog" + version = "0.1.0" + tier = ToolTier.SOURCE + capability = "3d_asset_acquisition" + provider = "multi" + stability = ToolStability.BETA + execution_mode = ExecutionMode.SYNC + determinism = Determinism.DETERMINISTIC + runtime = ToolRuntime.HYBRID + dependencies: list[str] = [] + install_instructions = "Network access for install; no API key. Bundled catalogs are CC0." + agent_skills = ["threejs-world-generation", "threejs-loaders", "threejs-materials", "threejs-textures"] + best_for = [ + "Installing rights-safe GLTF/GLB libraries for detailed Three.js worlds", + "Recording model-level provenance before asset-gate review", + ] + not_good_for = [ + "Generating a unique mesh from text or an image", + "Downloading assets whose license is absent or incompatible", + ] + capabilities = ["cc0_catalog_install", "gltf_inventory", "asset_provenance"] + input_schema = { + "type": "object", + "required": ["operation"], + "properties": { + "operation": {"type": "string", "enum": ["list", "install", "inspect"]}, + "catalog_id": {"type": "string"}, + "output_path": {"type": "string"}, + }, + } + output_schema = {"type": "object"} + artifact_schema = {"artifact": "3d_world"} + resource_profile = ResourceProfile(cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=1000, network_required=True) + idempotency_key_fields = ["operation", "catalog_id", "output_path"] + side_effects = ["downloads and extracts a licensed asset archive for install operations"] + fallback_tools: list[str] = [] + user_visible_verification = ["Review catalog-manifest.json and the model inventory before production use"] + + def execute(self, params: dict[str, Any]) -> ToolResult: + operation = params.get("operation") + if operation == "list": + return ToolResult(success=True, data={"catalogs": CATALOGS}) + + catalog_id = str(params.get("catalog_id") or "") + if catalog_id not in CATALOGS: + return ToolResult(success=False, error=f"Unknown catalog_id {catalog_id!r}; choose one of {sorted(CATALOGS)}") + + output_path = params.get("output_path") + if not output_path: + return ToolResult(success=False, error="output_path is required for install and inspect") + root = Path(output_path).expanduser().resolve() + manifest_path = root / "catalog-manifest.json" + + if operation == "inspect": + if not manifest_path.exists(): + return ToolResult(success=False, error=f"No installed catalog manifest at {manifest_path}") + return ToolResult(success=True, data=json.loads(manifest_path.read_text(encoding="utf-8"))) + + if operation != "install": + return ToolResult(success=False, error=f"Unsupported operation {operation!r}") + + source = CATALOGS[catalog_id] + root.mkdir(parents=True, exist_ok=True) + archive = root / f"{catalog_id}.zip" + if not archive.exists(): + _download(source["download_url"], archive) + extract_root = root / "source" + if not extract_root.exists(): + extract_root.mkdir(parents=True) + with zipfile.ZipFile(archive) as package: + package.extractall(extract_root) + + models = sorted( + path for path in extract_root.rglob("*") + if path.is_file() and path.suffix.lower() in {".gltf", ".glb"} + ) + textures = sorted( + path for path in extract_root.rglob("*") + if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"} + ) + manifest = { + "version": "1.0", + "catalog_id": catalog_id, + **source, + "archive_sha256": _sha256(archive), + "model_count": len(models), + "texture_count": len(textures), + "models": [ + { + "id": path.stem.lower().replace(" ", "-"), + "path": path.relative_to(root).as_posix(), + "format": path.suffix.lower().lstrip("."), + } + for path in models + ], + } + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return ToolResult(success=True, data=manifest, artifacts=[str(manifest_path)]) diff --git a/tools/graphics/threejs_world.py b/tools/graphics/threejs_world.py new file mode 100644 index 00000000..e939f9b7 --- /dev/null +++ b/tools/graphics/threejs_world.py @@ -0,0 +1,732 @@ +"""Deterministic semantic Three.js world authoring for HyperFrames. + +The agent owns creative planning. This tool validates and normalizes a structured +world specification, materializes an editable Three.js workspace, and emits a +diagnostic report. Rendering remains the responsibility of video_compose / +hyperframes_compose so pipeline governance and review stay intact. +""" + +from __future__ import annotations + +import copy +import html +import json +import math +import re +import shutil +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + ToolResult, + ToolRuntime, + ToolStability, + ToolTier, +) + + +_HEX = re.compile(r"^#[0-9a-fA-F]{6}$") +_LANDFORMS = {"plain", "peak", "ridge", "dune", "terrace", "basin", "canyon"} +_LANDMARKS = {"monolith", "arch", "tower", "ruin", "crystal", "settlement", "ring"} +_RENDER_MODES = {"cinematic", "semantic", "wireframe"} +_QUALITY_TIERS = {"blockout", "production"} + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _number(value: Any, default: float) -> float: + try: + number = float(value) + return number if math.isfinite(number) else default + except (TypeError, ValueError): + return default + + +def _integer(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _slug(value: Any, fallback: str) -> str: + text = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return text or fallback + + +def _color(value: Any, default: str) -> str: + text = str(value or "") + return text if _HEX.fullmatch(text) else default + + +def _vec(value: Any, length: int, default: list[float]) -> list[float]: + if not isinstance(value, (list, tuple)) or len(value) != length: + return list(default) + return [_number(component, default[index]) for index, component in enumerate(value)] + + +class ThreeJSWorld(BaseTool): + """Build and validate an editable semantic world workspace.""" + + name = "threejs_world" + version = "0.2.0" + tier = ToolTier.GENERATE + capability = "3d_world_generation" + provider = "threejs" + stability = ToolStability.BETA + execution_mode = ExecutionMode.SYNC + determinism = Determinism.SEEDED + runtime = ToolRuntime.LOCAL + dependencies: list[str] = [] + install_instructions = ( + "World authoring is dependency-free. Final rendering requires the configured " + "HyperFrames runtime (Node.js >= 22, npx, and FFmpeg)." + ) + agent_skills = ["threejs-world-generation"] + capabilities = [ + "semantic_region_planning", + "procedural_height_field", + "region_aware_asset_scattering", + "explicit_landmark_placement", + "deterministic_camera_flythrough", + "semantic_and_wireframe_diagnostics", + "hyperframes_atelier_workspace", + "licensed_gltf_asset_palette", + "production_fidelity_gate", + "pbr_terrain_material_contract", + ] + best_for = [ + "Editable cinematic 3D worlds and terrain fly-throughs", + "Free-viewpoint environments built without paid generation APIs", + "Region-aware terrain, biomes, landmarks, and diagnostic passes", + "Production worlds assembled from local licensed GLTF/PBR catalogs", + ] + not_good_for = [ + "Single-view mesh reconstruction without a separately configured provider", + "Articulated characters, physics, navmeshes, or interactive game logic", + "Single isolated product models where a normal Three.js scene is simpler", + ] + input_schema = { + "type": "object", + "required": ["operation", "world_spec"], + "properties": { + "operation": {"type": "string", "enum": ["build", "validate"]}, + "world_spec": {"type": "object"}, + "output_path": {"type": "string"}, + "duration_seconds": {"type": "number", "minimum": 1, "maximum": 600}, + "width": {"type": "integer", "minimum": 320, "maximum": 7680}, + "height": {"type": "integer", "minimum": 240, "maximum": 4320}, + "render_mode": { + "type": "string", + "enum": ["cinematic", "semantic", "wireframe"], + }, + "quality_tier": {"type": "string", "enum": ["blockout", "production"]}, + "asset_catalog_paths": {"type": "array", "items": {"type": "string"}}, + }, + } + output_schema = { + "type": "object", + "properties": { + "workspace": {"type": "string"}, + "entry": {"type": "string"}, + "world_spec": {"type": "object"}, + "report": {"type": "object"}, + }, + } + artifact_schema = {"artifact": "3d_world"} + resource_profile = ResourceProfile( + cpu_cores=2, ram_mb=1024, vram_mb=1024, disk_mb=2000, network_required=True + ) + idempotency_key_fields = ["operation", "world_spec", "duration_seconds", "render_mode", "quality_tier", "asset_catalog_paths"] + side_effects = [ + "writes an editable HyperFrames/Three.js workspace to output_path", + "writes normalized world and diagnostic JSON files", + ] + fallback_tools: list[str] = [] + user_visible_verification = [ + "Inspect semantic, regional, and walk-level snapshots before final render", + "Verify landmark contact, camera clearance, and stable region identities", + "Open index.html with HyperFrames preview to explore the authored camera path", + ] + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + started = time.time() + operation = str(inputs.get("operation", "")) + duration = _clamp(_number(inputs.get("duration_seconds"), 60.0), 1.0, 600.0) + width = int(_clamp(_integer(inputs.get("width"), 1920), 320, 7680)) + height = int(_clamp(_integer(inputs.get("height"), 1080), 240, 4320)) + render_mode = str(inputs.get("render_mode") or "cinematic").lower() + if render_mode not in _RENDER_MODES: + return ToolResult(success=False, error=f"Unknown render_mode: {render_mode}") + quality_tier = str(inputs.get("quality_tier") or "blockout").lower() + if quality_tier not in _QUALITY_TIERS: + return ToolResult(success=False, error=f"Unknown quality_tier: {quality_tier}") + catalog_paths = [Path(str(path)).expanduser().resolve() for path in inputs.get("asset_catalog_paths") or []] + + spec, normalize_warnings = self._normalize_spec( + inputs.get("world_spec") or {}, duration=duration + ) + report = self._report(spec, duration=duration, warnings=normalize_warnings) + report["quality_tier"] = quality_tier + report["asset_catalog_paths"] = [str(path) for path in catalog_paths] + fidelity_errors, fidelity_warnings = self._fidelity_gate(spec, quality_tier, catalog_paths) + report["errors"].extend(fidelity_errors) + report["warnings"].extend(fidelity_warnings) + + if operation == "validate": + return ToolResult( + success=not report["errors"], + data={"world_spec": spec, "report": report}, + error="; ".join(report["errors"]) if report["errors"] else None, + duration_seconds=round(time.time() - started, 2), + seed=spec["seed"], + model=f"threejs-world-{quality_tier}-v2", + ) + + if operation != "build": + return ToolResult(success=False, error=f"Unknown operation: {operation}") + if report["errors"]: + return ToolResult( + success=False, + data={"world_spec": spec, "report": report}, + error="World specification failed validation: " + "; ".join(report["errors"]), + ) + + output_raw = inputs.get("output_path") + if not output_raw: + return ToolResult(success=False, error="output_path is required for operation='build'") + workspace = Path(str(output_raw)).expanduser().resolve() + + try: + artifacts = self._write_workspace( + workspace=workspace, + spec=spec, + report=report, + duration=duration, + width=width, + height=height, + render_mode=render_mode, + quality_tier=quality_tier, + catalog_paths=catalog_paths, + ) + except Exception as exc: + return ToolResult(success=False, error=f"3D world build failed: {exc}") + + return ToolResult( + success=True, + data={ + "workspace": str(workspace), + "entry": str(workspace / "index.html"), + "world_spec": spec, + "report": report, + "render_mode": render_mode, + "duration_seconds": duration, + "width": width, + "height": height, + }, + artifacts=artifacts, + duration_seconds=round(time.time() - started, 2), + seed=spec["seed"], + model=f"threejs-world-{quality_tier}-v2", + ) + + @staticmethod + def _fidelity_gate( + spec: dict[str, Any], quality_tier: str, catalog_paths: list[Path] + ) -> tuple[list[str], list[str]]: + if quality_tier == "blockout": + return [], [ + "Blockout tier may use procedural primitives and flat materials; " + "do not present it as reference-grade or production-fidelity output." + ] + + errors: list[str] = [] + warnings: list[str] = [] + manifests: list[dict[str, Any]] = [] + for catalog_path in catalog_paths: + manifest_path = catalog_path / "catalog-manifest.json" + if not manifest_path.is_file(): + errors.append(f"Production catalog manifest missing: {manifest_path}") + continue + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"Production catalog manifest unreadable: {manifest_path}: {exc}") + continue + if manifest.get("license") not in {"CC0", "CC0-1.0"}: + errors.append(f"Catalog {manifest_path} lacks an approved CC0 license declaration.") + if int(manifest.get("model_count") or 0) <= 0: + errors.append(f"Catalog {manifest_path} contains no GLTF/GLB models.") + manifests.append(manifest) + + asset_palette = spec.get("asset_palette") or [] + terrain_materials = spec.get("terrain_materials") or [] + if not catalog_paths: + errors.append("Production tier requires at least one installed asset catalog path.") + if len(asset_palette) < 8: + errors.append("Production tier requires at least 8 distinct asset-palette entries.") + if len(terrain_materials) < 3: + errors.append("Production tier requires at least 3 terrain material layers.") + if any(not item.get("catalog_id") or not item.get("model_id") for item in asset_palette): + errors.append("Every production asset-palette entry requires catalog_id and model_id.") + if any(not item.get("base_color") or not item.get("normal") or not item.get("roughness") for item in terrain_materials): + errors.append("Every production terrain material requires base_color, normal, and roughness maps.") + + unique_categories = {str(item.get("category") or "") for item in asset_palette} + if len(unique_categories - {""}) < 4: + errors.append("Production asset palette requires at least 4 semantic categories.") + if len(manifests) == 1: + warnings.append("Only one asset catalog is installed; repetition must be checked at walk level.") + return errors, warnings + + @classmethod + def _normalize_spec( + cls, raw: dict[str, Any], *, duration: float + ) -> tuple[dict[str, Any], list[str]]: + source = copy.deepcopy(raw) if isinstance(raw, dict) else {} + warnings: list[str] = [] + world_raw = source.get("world") if isinstance(source.get("world"), dict) else {} + atmosphere_raw = ( + source.get("atmosphere") if isinstance(source.get("atmosphere"), dict) else {} + ) + terrain_materials_raw = source.get("terrain_materials") if isinstance(source.get("terrain_materials"), list) else [] + asset_palette_raw = source.get("asset_palette") if isinstance(source.get("asset_palette"), list) else [] + + world = { + "size": _clamp(_number(world_raw.get("size"), 120.0), 24.0, 500.0), + "resolution": int( + _clamp(_integer(world_raw.get("resolution"), 144), 24, 256) + ), + "elevation_scale": _clamp( + _number(world_raw.get("elevation_scale"), 16.0), 1.0, 80.0 + ), + "water_level": _clamp( + _number(world_raw.get("water_level"), -2.0), -60.0, 60.0 + ), + } + atmosphere = { + "sky_color": _color(atmosphere_raw.get("sky_color"), "#07111f"), + "fog_color": _color(atmosphere_raw.get("fog_color"), "#13263a"), + "fog_density": _clamp( + _number(atmosphere_raw.get("fog_density"), 0.008), 0.0, 0.08 + ), + "sun_color": _color(atmosphere_raw.get("sun_color"), "#ffd7a3"), + "sun_intensity": _clamp( + _number(atmosphere_raw.get("sun_intensity"), 3.0), 0.0, 12.0 + ), + "sun_position": _vec( + atmosphere_raw.get("sun_position"), 3, [45.0, 70.0, 20.0] + ), + "ground_color": _color(atmosphere_raw.get("ground_color"), "#151c23"), + } + + palette = ["#315b48", "#73523d", "#2d5968", "#6f4b78", "#8b753f"] + accent_palette = ["#8ee6b1", "#ff9a62", "#64d8ff", "#d4a8ff", "#ffe27a"] + regions: list[dict[str, Any]] = [] + raw_regions = source.get("regions") if isinstance(source.get("regions"), list) else [] + for index, item in enumerate(raw_regions[:12]): + item = item if isinstance(item, dict) else {} + region_id = _slug(item.get("id") or item.get("label"), f"region-{index + 1}") + landform = str(item.get("landform") or "plain").lower() + if landform not in _LANDFORMS: + warnings.append( + f"Region {region_id}: unknown landform {landform!r}; using 'plain'." + ) + landform = "plain" + scatter_raw = item.get("scatter") if isinstance(item.get("scatter"), dict) else {} + center = _vec(item.get("center"), 2, [0.0, 0.0]) + center = [_clamp(center[0], -1.0, 1.0), _clamp(center[1], -1.0, 1.0)] + regions.append( + { + "id": region_id, + "label": str(item.get("label") or region_id.replace("-", " ").title()), + "center": center, + "radius": _clamp(_number(item.get("radius"), 0.75), 0.12, 2.5), + "base_elevation": _clamp( + _number(item.get("base_elevation"), 0.0), -2.0, 2.0 + ), + "amplitude": _clamp(_number(item.get("amplitude"), 0.65), 0.0, 2.5), + "frequency": _clamp(_number(item.get("frequency"), 1.0), 0.15, 8.0), + "landform": landform, + "blend_width": _clamp( + _number(item.get("blend_width"), 0.22), 0.02, 1.0 + ), + "color": _color(item.get("color"), palette[index % len(palette)]), + "accent_color": _color( + item.get("accent_color"), accent_palette[index % len(accent_palette)] + ), + "scatter": { + "tree": int( + _clamp(_integer(scatter_raw.get("tree"), 0), 0, 1200) + ), + "rock": int( + _clamp(_integer(scatter_raw.get("rock"), 35), 0, 1200) + ), + "crystal": int( + _clamp(_integer(scatter_raw.get("crystal"), 0), 0, 1200) + ), + }, + "slope_limit": _clamp( + _number(item.get("slope_limit"), 1.8), 0.1, 12.0 + ), + } + ) + + landmarks: list[dict[str, Any]] = [] + raw_landmarks = ( + source.get("landmarks") if isinstance(source.get("landmarks"), list) else [] + ) + fallback_region = regions[0]["id"] if regions else "" + for index, item in enumerate(raw_landmarks[:80]): + item = item if isinstance(item, dict) else {} + landmark_id = _slug(item.get("id"), f"landmark-{index + 1}") + kind = str(item.get("type") or "monolith").lower() + if kind not in _LANDMARKS: + warnings.append( + f"Landmark {landmark_id}: unknown type {kind!r}; using 'monolith'." + ) + kind = "monolith" + landmarks.append( + { + "id": landmark_id, + "type": kind, + "region_id": _slug(item.get("region_id"), fallback_region), + "position": _vec(item.get("position"), 3, [0.0, 0.0, 0.0]), + "rotation": _vec(item.get("rotation"), 3, [0.0, 0.0, 0.0]), + "scale": _clamp(_number(item.get("scale"), 4.0), 0.2, 30.0), + "color": _color(item.get("color"), "#30343b"), + "accent_color": _color(item.get("accent_color"), "#74e5ff"), + } + ) + + camera_path: list[dict[str, Any]] = [] + raw_camera = ( + source.get("camera_path") if isinstance(source.get("camera_path"), list) else [] + ) + for index, item in enumerate(raw_camera[:40]): + item = item if isinstance(item, dict) else {} + default_time = (duration * index / max(1, len(raw_camera) - 1)) if raw_camera else 0 + camera_path.append( + { + "time": _clamp(_number(item.get("time"), default_time), 0.0, duration), + "position": _vec(item.get("position"), 3, [60.0, 35.0, 60.0]), + "target": _vec(item.get("target"), 3, [0.0, 0.0, 0.0]), + "fov": _clamp(_number(item.get("fov"), 45.0), 18.0, 90.0), + "label": str(item.get("label") or ""), + } + ) + camera_path.sort(key=lambda key: key["time"]) + + spec = { + "version": str(source.get("version") or "1.0"), + "title": str(source.get("title") or "Untitled Three.js World"), + "seed": _integer(source.get("seed"), 1337), + "explicit_constraints": [ + str(value) + for value in source.get("explicit_constraints", []) + if str(value).strip() + ] + if isinstance(source.get("explicit_constraints"), list) + else [], + "inferred_details": [ + str(value) + for value in source.get("inferred_details", []) + if str(value).strip() + ] + if isinstance(source.get("inferred_details"), list) + else [], + "world": world, + "atmosphere": atmosphere, + "terrain_materials": [copy.deepcopy(item) for item in terrain_materials_raw if isinstance(item, dict)], + "asset_palette": [copy.deepcopy(item) for item in asset_palette_raw if isinstance(item, dict)], + "regions": regions, + "landmarks": landmarks, + "camera_path": camera_path, + } + return spec, warnings + + @classmethod + def _report( + cls, spec: dict[str, Any], *, duration: float, warnings: list[str] + ) -> dict[str, Any]: + errors: list[str] = [] + warnings = list(warnings) + regions = spec["regions"] + landmarks = spec["landmarks"] + camera_path = spec["camera_path"] + + if not regions: + errors.append("At least one semantic region is required.") + region_ids = [region["id"] for region in regions] + if len(region_ids) != len(set(region_ids)): + errors.append("Region IDs must be unique.") + landmark_ids = [landmark["id"] for landmark in landmarks] + if len(landmark_ids) != len(set(landmark_ids)): + errors.append("Landmark IDs must be unique.") + for landmark in landmarks: + if landmark["region_id"] not in set(region_ids): + errors.append( + f"Landmark {landmark['id']} references unknown region " + f"{landmark['region_id']!r}." + ) + + if len(camera_path) < 2: + errors.append("Camera path requires at least two time keys.") + else: + if abs(camera_path[0]["time"]) > 1e-6: + errors.append("First camera key must start at time 0.") + if abs(camera_path[-1]["time"] - duration) > 1e-3: + errors.append( + f"Last camera key must end at duration {duration:g} seconds." + ) + times = [key["time"] for key in camera_path] + if any(right <= left for left, right in zip(times, times[1:])): + errors.append("Camera key times must be strictly increasing.") + + size = spec["world"]["size"] + half = size / 2.0 + for landmark in landmarks: + x, _, z = landmark["position"] + if abs(x) > half or abs(z) > half: + warnings.append(f"Landmark {landmark['id']} is outside world bounds.") + + coverage: dict[str, int] = {region_id: 0 for region_id in region_ids} + if regions: + for iz in range(15): + for ix in range(15): + x = (ix / 14.0) * 2.0 - 1.0 + z = (iz / 14.0) * 2.0 - 1.0 + weights = cls._region_weights(spec, x, z) + winner = max(range(len(weights)), key=weights.__getitem__) + coverage[regions[winner]["id"]] += 1 + for region_id, samples in coverage.items(): + if samples == 0: + warnings.append( + f"Region {region_id} never dominates the sampled semantic layout." + ) + + min_clearance: float | None = None + if len(camera_path) >= 2 and regions: + for sample_index in range(121): + sample_time = duration * sample_index / 120.0 + position = cls._interpolate_camera(camera_path, sample_time) + terrain_y = cls._height_at(spec, position[0], position[2]) + clearance = position[1] - terrain_y + min_clearance = clearance if min_clearance is None else min(min_clearance, clearance) + if min_clearance is not None and min_clearance < 2.0: + warnings.append( + f"Camera path minimum terrain clearance is {min_clearance:.2f}; " + "review for clipping." + ) + + resolution = spec["world"]["resolution"] + instance_count = sum(sum(region["scatter"].values()) for region in regions) + return { + "valid": not errors, + "errors": errors, + "warnings": warnings, + "stats": { + "region_count": len(regions), + "landmark_count": len(landmarks), + "camera_key_count": len(camera_path), + "terrain_triangles": resolution * resolution * 2, + "environment_instances": instance_count, + "semantic_coverage_samples": coverage, + "minimum_camera_clearance": ( + round(min_clearance, 3) if min_clearance is not None else None + ), + }, + "review_views": ["global", "regional", "walk", "semantic", "wireframe"], + "diagnostic_passes": { + "cinematic": "lit beauty render for final review", + "semantic": "stable region-color pass for layout review", + "wireframe": "explicit terrain and asset geometry pass", + }, + } + + @classmethod + def _region_weights(cls, spec: dict[str, Any], nx: float, nz: float) -> list[float]: + raw: list[float] = [] + for region in spec["regions"]: + dx = nx - region["center"][0] + dz = nz - region["center"][1] + radius = max(0.05, region["radius"]) + distance = math.sqrt(dx * dx + dz * dz) / radius + softness = max(0.02, region["blend_width"]) + value = math.exp(-max(0.0, distance - 0.05) ** 2 / (softness * 2.8)) + raw.append(max(1e-5, value)) + total = sum(raw) or 1.0 + return [value / total for value in raw] + + @classmethod + def _height_at(cls, spec: dict[str, Any], x: float, z: float) -> float: + size = spec["world"]["size"] + nx = x / (size / 2.0) + nz = z / (size / 2.0) + weights = cls._region_weights(spec, nx, nz) + seed = spec["seed"] * 0.01337 + elevation = 0.0 + for index, (region, weight) in enumerate(zip(spec["regions"], weights)): + frequency = region["frequency"] + noise = ( + math.sin((nx * 3.1 + seed + index) * frequency * math.pi) + + math.cos((nz * 2.7 - seed * 0.7 + index) * frequency * math.pi) + + 0.5 + * math.sin((nx + nz) * frequency * 7.3 + seed * 3.0 + index) + ) / 2.5 + dx = nx - region["center"][0] + dz = nz - region["center"][1] + distance = math.sqrt(dx * dx + dz * dz) / max(0.05, region["radius"]) + landform = cls._landform(region["landform"], dx, dz, distance) + elevation += weight * ( + region["base_elevation"] + + region["amplitude"] * (noise * 0.48 + landform * 0.8) + ) + return elevation * spec["world"]["elevation_scale"] + + @staticmethod + def _landform(kind: str, dx: float, dz: float, distance: float) -> float: + if kind == "peak": + return max(0.0, 1.0 - distance) ** 2.2 + if kind == "ridge": + return max(0.0, 1.0 - abs(dx * 1.8 + math.sin(dz * 5.0) * 0.16)) + if kind == "dune": + return (math.sin((dx + dz * 0.25) * 18.0) + 1.0) * 0.24 + if kind == "terrace": + return math.floor(max(0.0, 1.0 - distance) * 5.0) / 5.0 + if kind == "basin": + return -max(0.0, 1.0 - distance) ** 1.7 + if kind == "canyon": + return -max(0.0, 1.0 - abs(dx + math.sin(dz * 7.0) * 0.1)) ** 2.0 + return 0.0 + + @staticmethod + def _interpolate_camera(camera_path: list[dict[str, Any]], time_value: float) -> list[float]: + if time_value <= camera_path[0]["time"]: + return list(camera_path[0]["position"]) + if time_value >= camera_path[-1]["time"]: + return list(camera_path[-1]["position"]) + for left, right in zip(camera_path, camera_path[1:]): + if left["time"] <= time_value <= right["time"]: + span = max(1e-6, right["time"] - left["time"]) + t = _clamp((time_value - left["time"]) / span, 0.0, 1.0) + smooth = t * t * (3.0 - 2.0 * t) + return [ + left["position"][axis] + + (right["position"][axis] - left["position"][axis]) * smooth + for axis in range(3) + ] + return list(camera_path[-1]["position"]) + + @staticmethod + def _write_workspace( + *, + workspace: Path, + spec: dict[str, Any], + report: dict[str, Any], + duration: float, + width: int, + height: int, + render_mode: str, + quality_tier: str, + catalog_paths: list[Path], + ) -> list[str]: + template_dir = Path(__file__).resolve().parent / "templates" / "threejs_world" + required = ["index.html", "world.css", "world-runtime.js"] + missing = [name for name in required if not (template_dir / name).is_file()] + if missing: + raise FileNotFoundError(f"Missing Three.js world templates: {', '.join(missing)}") + + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "assets").mkdir(exist_ok=True) + (workspace / "renders").mkdir(exist_ok=True) + + catalog_index: dict[str, Any] = {"version": "1.0", "catalogs": []} + model_root = workspace / "assets" / "models" + model_root.mkdir(parents=True, exist_ok=True) + for catalog_path in catalog_paths: + manifest_path = catalog_path / "catalog-manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + catalog_id = str(manifest["catalog_id"]) + target = model_root / catalog_id + source = catalog_path / "source" + if target.exists(): + shutil.rmtree(target) + shutil.copytree(source, target) + copied = copy.deepcopy(manifest) + for model in copied.get("models", []): + original = Path(model["path"]) + relative_inside_source = Path(*original.parts[1:]) if original.parts and original.parts[0] == "source" else original + model["runtime_path"] = (Path("assets") / "models" / catalog_id / relative_inside_source).as_posix() + copied.pop("download_url", None) + catalog_index["catalogs"].append(copied) + + index_template = (template_dir / "index.html").read_text(encoding="utf-8") + index_html = ( + index_template.replace("__TITLE__", html.escape(spec["title"], quote=True)) + .replace("__DURATION__", f"{duration:g}") + .replace("__WIDTH__", str(width)) + .replace("__HEIGHT__", str(height)) + .replace("__RENDER_MODE__", render_mode) + .replace("__QUALITY_TIER__", quality_tier) + ) + + index_path = workspace / "index.html" + css_path = workspace / "world.css" + runtime_path = workspace / "world-runtime.js" + world_json_path = workspace / "world.json" + world_js_path = workspace / "world-spec.js" + report_path = workspace / "world-report.json" + catalog_index_path = workspace / "asset-catalog-index.json" + catalog_js_path = workspace / "asset-catalog.js" + config_path = workspace / "hyperframes.json" + + index_path.write_text(index_html, encoding="utf-8") + shutil.copyfile(template_dir / "world.css", css_path) + shutil.copyfile(template_dir / "world-runtime.js", runtime_path) + world_json_path.write_text(json.dumps(spec, indent=2), encoding="utf-8") + world_js_path.write_text( + "export const WORLD_SPEC = " + json.dumps(spec, indent=2) + ";\n", + encoding="utf-8", + ) + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + catalog_index_path.write_text(json.dumps(catalog_index, indent=2), encoding="utf-8") + catalog_js_path.write_text( + "export const ASSET_CATALOG = " + json.dumps(catalog_index, indent=2) + ";\n", + encoding="utf-8", + ) + config_path.write_text( + json.dumps( + { + "registry": ( + "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry" + ), + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets", + }, + }, + indent=2, + ), + encoding="utf-8", + ) + + return [ + str(index_path), + str(css_path), + str(runtime_path), + str(world_json_path), + str(world_js_path), + str(report_path), + str(catalog_index_path), + str(catalog_js_path), + str(config_path), + ] diff --git a/tools/video/hyperframes_compose.py b/tools/video/hyperframes_compose.py index 7511b686..351529d1 100644 --- a/tools/video/hyperframes_compose.py +++ b/tools/video/hyperframes_compose.py @@ -2,10 +2,11 @@ Sibling to `video_compose` (FFmpeg + Remotion). This tool owns the HyperFrames runtime end-to-end: workspace materialization, `hyperframes lint`, -`hyperframes validate`, and `hyperframes render`. It is invoked by +`hyperframes check`, and `hyperframes render`. It is invoked by `video_compose` when `edit_decisions.render_runtime == "hyperframes"`, and can also be called directly by pipelines that want HyperFrames-specific -operations (lint-only, validate-only, scaffold-only). +operations (check/lint/validate/inspect, scaffold-only, or an +existing-workspace atelier render that preserves authored HTML). This tool deliberately does NOT attempt parity with every Remotion scene component. See `skills/core/hyperframes.md` for what is in scope in Phase 1 @@ -49,7 +50,7 @@ _AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"} class HyperFramesCompose(BaseTool): name = "hyperframes_compose" - version = "0.1.0" + version = "0.2.0" tier = ToolTier.CORE capability = "video_post" provider = "hyperframes" @@ -72,7 +73,7 @@ class HyperFramesCompose(BaseTool): "hyperframes", "hyperframes-cli", "hyperframes-registry", - "website-to-hyperframes", + "website-to-video", "gsap-core", "gsap-timeline", ] @@ -81,8 +82,11 @@ class HyperFramesCompose(BaseTool): "hyperframes_render", "hyperframes_lint", "hyperframes_validate", + "hyperframes_inspect", + "hyperframes_check", "hyperframes_doctor", "scaffold_workspace", + "render_existing_workspace", "add_block", ] @@ -91,6 +95,7 @@ class HyperFramesCompose(BaseTool): "Motion-graphics-heavy briefs where the scene library in remotion-composer/ doesn't fit", "Website-to-video / UI-driven compositions", "Registry-block-driven scenes (hyperframes add data-chart, grain-overlay, etc.)", + "Hand-authored atelier workspaces, including deterministic Three.js worlds", ] not_good_for = [ "Word-level caption burn (stays on Remotion in Phase 1)", @@ -107,16 +112,22 @@ class HyperFramesCompose(BaseTool): "type": "string", "enum": [ "render", + "render_existing", "lint", "validate", + "inspect", + "check", "doctor", "scaffold_workspace", "add_block", ], "description": ( "render: materialize workspace + lint + validate + render to MP4. " + "render_existing: preserve an authored index.html, then check + render it. " "lint: run `hyperframes lint` on an existing workspace. " "validate: run `hyperframes validate` (browser-based). " + "inspect: seek an existing workspace and audit layout/runtime issues. " + "check: run the current unified lint/runtime/layout/motion/contrast gate. " "doctor: run `hyperframes doctor` to check environment. " "scaffold_workspace: materialize HTML/CSS/assets but do not render. " "add_block: run `hyperframes add ` to install a registry " @@ -141,7 +152,7 @@ class HyperFramesCompose(BaseTool): }, "output_path": { "type": "string", - "description": "Output MP4 path. Used by operation='render'.", + "description": "Output MP4 path. Used by render and render_existing.", }, "edit_decisions": { "type": "object", @@ -191,15 +202,25 @@ class HyperFramesCompose(BaseTool): "type": "boolean", "default": False, "description": ( - "Skip the WCAG contrast audit during validate. Acceptable " + "Skip the WCAG contrast audit during check. Acceptable " "while iterating; forbidden for final delivery." ), }, + "strict_check": { + "type": "boolean", + "default": False, + "description": "Treat HyperFrames check warnings as errors.", + }, + "snapshots": { + "type": "boolean", + "default": False, + "description": "Save representative quality-check snapshots.", + }, }, } resource_profile = ResourceProfile( - cpu_cores=4, ram_mb=3072, vram_mb=0, disk_mb=2000, network_required=False + cpu_cores=4, ram_mb=3072, vram_mb=0, disk_mb=2000, network_required=True ) retry_policy = RetryPolicy(max_retries=0) resume_support = ResumeSupport.FROM_START @@ -447,8 +468,14 @@ class HyperFramesCompose(BaseTool): result = self._lint(inputs) elif operation == "validate": result = self._validate(inputs) + elif operation == "inspect": + result = self._inspect(inputs) + elif operation == "check": + result = self._check(inputs) elif operation == "render": result = self._render(inputs) + elif operation == "render_existing": + result = self._render_existing(inputs) elif operation == "add_block": result = self._add_block(inputs) else: @@ -640,6 +667,56 @@ class HyperFramesCompose(BaseTool): error=None if ok else f"hyperframes validate exit {proc.returncode}", ) + def _inspect(self, inputs: dict[str, Any]) -> ToolResult: + """Seek through an authored workspace and audit runtime/layout issues.""" + workspace = self._require_workspace(inputs) + if not (workspace / "index.html").exists(): + return ToolResult( + success=False, + error=f"No index.html in {workspace}.", + ) + proc = self._run_hf(["inspect", "--json"], cwd=workspace, timeout=300, check=False) + data: dict[str, Any] = {"exit_code": proc.returncode} + payload = self._parse_json_output(proc.stdout) + if payload is not None: + data["report"] = payload + else: + data["stdout_tail"] = (proc.stdout or "")[-4000:] + data["stderr_tail"] = (proc.stderr or "")[-2000:] + ok = proc.returncode == 0 + return ToolResult( + success=ok, + data=data, + error=None if ok else f"hyperframes inspect exit {proc.returncode}", + ) + + def _check(self, inputs: dict[str, Any]) -> ToolResult: + """Run the unified HyperFrames quality gate for authored workspaces.""" + workspace = self._require_workspace(inputs) + if not (workspace / "index.html").exists(): + return ToolResult(success=False, error=f"No index.html in {workspace}.") + args = ["check", "--json"] + if inputs.get("skip_contrast", False): + args.append("--no-contrast") + if inputs.get("strict_check", False): + args.append("--strict") + if inputs.get("snapshots", False): + args.append("--snapshots") + proc = self._run_hf(args, cwd=workspace, timeout=300, check=False) + data: dict[str, Any] = {"exit_code": proc.returncode} + payload = self._parse_json_output(proc.stdout) + if payload is not None: + data["report"] = payload + else: + data["stdout_tail"] = (proc.stdout or "")[-4000:] + data["stderr_tail"] = (proc.stderr or "")[-2000:] + ok = proc.returncode == 0 + return ToolResult( + success=ok, + data=data, + error=None if ok else f"hyperframes check exit {proc.returncode}", + ) + def _add_block(self, inputs: dict[str, Any]) -> ToolResult: """Install a registry block or component via `hyperframes add`. @@ -798,6 +875,111 @@ class HyperFramesCompose(BaseTool): artifacts=[str(output_path)], ) + def _render_existing(self, inputs: dict[str, Any]) -> ToolResult: + """Validate and render a hand-authored workspace without scaffolding it. + + Atelier compositions own their HTML, CSS, JavaScript, and local assets. + Re-running `_scaffold` would destroy that authored work, so this path + performs the mandatory gates against the files already on disk. + """ + runtime_ok = self._runtime_check() + if not runtime_ok["runtime_available"]: + return ToolResult( + success=False, + error=( + "HyperFrames runtime not available: " + + "; ".join(runtime_ok["reasons"]) + + ". Per governance, do not swap runtimes silently." + ), + data={"runtime_check": runtime_ok}, + ) + + workspace = self._require_workspace(inputs) + entry = workspace / "index.html" + if not entry.is_file(): + return ToolResult( + success=False, + error=f"No authored index.html in {workspace}.", + ) + original_digest = self._file_digest(entry) + output_path = Path( + inputs.get("output_path") or (workspace / "renders" / "final.mp4") + ).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + steps: dict[str, Any] = {} + + quality_check = self._check( + { + "workspace_path": str(workspace), + "skip_contrast": inputs.get("skip_contrast", False), + "strict_check": inputs.get("strict_check", False), + "snapshots": inputs.get("snapshots", False), + } + ) + steps["check"] = quality_check.data + if not quality_check.success: + return ToolResult( + success=False, + error=f"Quality check failed for authored workspace: {quality_check.error}", + data={"steps": steps}, + ) + + _, _, fps = self._resolve_dimensions( + inputs.get("profile"), inputs.get("fps", 30) + ) + quality = inputs.get("quality", "standard") + args = [ + "render", + "--output", str(output_path), + "--fps", str(fps), + "--quality", quality, + "--strict", + ] + proc = self._run_hf(args, cwd=workspace, timeout=1800, check=False) + steps["render"] = { + "exit_code": proc.returncode, + "stdout_tail": (proc.stdout or "")[-4000:], + "stderr_tail": (proc.stderr or "")[-4000:], + } + if proc.returncode != 0: + return ToolResult( + success=False, + error=f"hyperframes render exit {proc.returncode}", + data={"steps": steps}, + ) + if not output_path.is_file(): + return ToolResult( + success=False, + error=f"HyperFrames exited 0 but output is missing: {output_path}", + data={"steps": steps}, + ) + if self._file_digest(entry) != original_digest: + return ToolResult( + success=False, + error="Authored index.html changed during render_existing.", + data={"steps": steps}, + ) + + return ToolResult( + success=True, + data={ + "operation": "render_existing", + "output": str(output_path), + "workspace": str(workspace), + "fps": fps, + "quality": quality, + "authored_entry_preserved": True, + "steps": steps, + }, + artifacts=[str(output_path)], + ) + + @staticmethod + def _file_digest(path: Path) -> str: + import hashlib + + return hashlib.sha256(path.read_bytes()).hexdigest() + # ------------------------------------------------------------------ # Workspace generation helpers # ------------------------------------------------------------------ diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index 6fd69ef4..7bf3ff90 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -1529,6 +1529,31 @@ class VideoCompose(BaseTool): if render_runtime == "remotion" and remotion_atelier_requested: return self._render_via_atelier(inputs, edit_decisions) + # HyperFrames is HTML-first and therefore atelier by default for hero + # work. When a project-local authored workspace already exists, route + # before the stock cut/asset requirements so hyperframes_compose can + # validate and render it without overwriting index.html. + hyperframes_atelier_requested = ( + render_runtime == "hyperframes" + and ( + edit_decisions.get("composition_mode") == "atelier" + or edit_decisions.get("renderer_family") == "bespoke" + or bool(edit_decisions.get("bespoke", {}).get("entry")) + ) + ) + if hyperframes_atelier_requested: + output_path = Path(inputs.get("output_path", "renders/output.mp4")) + output_path.parent.mkdir(parents=True, exist_ok=True) + profile = inputs.get("profile") or inputs.get("output_profile") + return self._render_via_hyperframes( + inputs=inputs, + edit_decisions=edit_decisions, + asset_manifest=asset_manifest or {"version": "1.0", "assets": []}, + resolved_cuts=list(edit_decisions.get("cuts") or []), + output_path=output_path, + profile=profile, + ) + if not asset_manifest: return ToolResult(success=False, error="asset_manifest required for render") @@ -1734,8 +1759,13 @@ class VideoCompose(BaseTool): ) playbook_data = None + authored_workspace = ( + edit_decisions.get("composition_mode") == "atelier" + or edit_decisions.get("renderer_family") == "bespoke" + or bool(edit_decisions.get("bespoke", {}).get("entry")) + ) hf_inputs: dict[str, Any] = { - "operation": "render", + "operation": "render_existing" if authored_workspace else "render", "workspace_path": workspace_path, "output_path": str(output_path), "edit_decisions": dict(edit_decisions, cuts=resolved_cuts), @@ -1753,6 +1783,10 @@ class VideoCompose(BaseTool): hf_inputs["strict"] = inputs["strict"] if "skip_contrast" in inputs: hf_inputs["skip_contrast"] = inputs["skip_contrast"] + if "strict_check" in inputs: + hf_inputs["strict_check"] = inputs["strict_check"] + if "snapshots" in inputs: + hf_inputs["snapshots"] = inputs["snapshots"] render_result = HyperFramesCompose().execute(hf_inputs) From 04571cfe8e1559387486ef1b3cdd6b5a7eafc582 Mon Sep 17 00:00:00 2001 From: calesthio Date: Thu, 13 Aug 2026 07:56:45 -0700 Subject: [PATCH 10/10] test: make Blender doctor contract portable --- tests/tools/test_3d_asset_generation.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_3d_asset_generation.py b/tests/tools/test_3d_asset_generation.py index 604b741d..9e86c33b 100644 --- a/tests/tools/test_3d_asset_generation.py +++ b/tests/tools/test_3d_asset_generation.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import subprocess from pathlib import Path import jsonschema @@ -10,6 +11,7 @@ import jsonschema from tools.base_tool import ToolStatus from tools.graphics import atlas_3d, fal_3d from tools.graphics.atlas_3d import Atlas3D +from tools.graphics import blender_world from tools.graphics.blender_world import BlenderWorld, first_missing_frame from tools.graphics.fal_3d import Fal3D from tools.tool_registry import ToolRegistry @@ -62,12 +64,25 @@ def test_fal_cost_matrix_and_input_validation(monkeypatch, tmp_path): assert not result.success -def test_blender_doctor_uses_verified_portable_runtime(): +def test_blender_doctor_reports_detected_runtime(monkeypatch, tmp_path): + executable = tmp_path / "blender" + executable.write_bytes(b"") + monkeypatch.setattr(blender_world, "find_blender", lambda: executable) + monkeypatch.setattr(blender_world.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess( + args=args[0], returncode=0, stdout="OPENMONTAGE_BLENDER=4.5.10 LTS\n", stderr="", + )) result = BlenderWorld().execute({"operation": "doctor"}) assert result.success, result.error assert result.data["version_line"].startswith("OPENMONTAGE_BLENDER=4.5.10") +def test_blender_doctor_explains_missing_optional_runtime(monkeypatch): + monkeypatch.setattr(blender_world, "find_blender", lambda: None) + result = BlenderWorld().execute({"operation": "doctor"}) + assert not result.success + assert "Blender not found" in (result.error or "") + + def test_blender_resume_finds_first_missing_contiguous_frame(tmp_path): prefix = tmp_path / "frame-" for frame in (1, 2, 4):