diff --git a/.agents/skills/dashscope/SKILL.md b/.agents/skills/dashscope/SKILL.md new file mode 100644 index 00000000..ccffbe6f --- /dev/null +++ b/.agents/skills/dashscope/SKILL.md @@ -0,0 +1,136 @@ +--- +name: dashscope +description: DashScope (Alibaba Cloud Bailian / 阿里云百炼) integration — image generation (qwen-image-2.0-pro), text-to-speech (qwen3-tts-flash), and ASR with word-level timestamps (qwen3-asr-flash-filetrans). Use when generating images via Qwen-Image, narrating via Qwen-TTS, or transcribing with word-level timestamps via Qwen-ASR. +--- + +# DashScope + +Requires `DASHSCOPE_API_KEY` in `.env`. Get one at https://dashscope.aliyun.com/. + +## Current API + +**CRITICAL:** DashScope's `/compatible-mode/v1/` only supports `/chat/completions` and `/embeddings`. Image generation, TTS, and ASR all use **DashScope-native endpoints** — not OpenAI-compatible paths. + +All three tools use `Authorization: Bearer $DASHSCOPE_API_KEY`. + +### Image Generation + +```text +POST https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation +``` + +- Model: `qwen-image-2.0-pro` (default), `qwen-image-max`, `wan2.7-image`, `z-image-turbo` +- Body: `{model, input: {messages: [{role: "user", content: [{text: "prompt"}]}]}, parameters: {size: "W*H", n, prompt_extend, watermark}}` +- **Size format uses asterisk:** `"1024*1024"` not `"1024x1024"` +- Response: `output.choices[0].message.content[0].image` (URL, valid ~24h) — must download separately + +### Text-to-Speech + +```text +POST https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation +``` + +Same endpoint as image gen, different body. + +- Model: `qwen3-tts-flash` (default), `qwen3-tts-instruct-flash`, `qwen-tts-2025-05-22` +- Body: `{model, input: {text, voice: "Cherry", language_type: "Auto"}}` +- Response: `output.audio.url` (WAV, valid ~24h) — must download separately + +### ASR with Word-Level Timestamps + +```text +POST https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription +Header: X-DashScope-Async: enable +``` + +- Model: `qwen3-asr-flash-filetrans` (NOT `qwen3-asr-flash` — the sync version has no word timestamps) +- Body: `{model, input: {file_url: "https://public-url/audio.mp3"}, parameters: {enable_words: true, language_hints: ["zh","en"]}}` +- Returns `task_id` → poll `GET /api/v1/tasks/{task_id}` until `SUCCEEDED` → download `output.result.transcription_url` → JSON with `transcripts[].sentences[].words[]` +- Timestamps in `begin_time`/`end_time` are in **milliseconds** — the tool normalizes to seconds + +## OpenMontage Usage + +### Image via selector + +```python +from tools.graphics.image_selector import ImageSelector + +result = ImageSelector().execute({ + "preferred_provider": "dashscope", + "prompt": "一只猫坐在沙发上", + "output_path": "projects/my-video/assets/images/cat.png", +}) +``` + +### TTS via selector + +```python +from tools.audio.tts_selector import TTSSelector + +result = TTSSelector().execute({ + "preferred_provider": "dashscope", + "text": "如果 AI 真的会改变未来,普通人到底该怎么参与?", + "voice": "Cherry", + "output_path": "projects/my-video/assets/audio/narration.wav", +}) +``` + +### ASR directly (word timestamps for subtitles) + +```python +from tools.analysis.dashscope_asr import DashscopeAsr + +result = DashscopeAsr().execute({ + "audio_url": "https://example.com/narration.wav", + "output_path": "projects/my-video/assets/audio/transcription.json", +}) + +# result.data["words"] is a flat list of {text, begin_time_seconds, end_time_seconds} +``` + +## Recommended Workflow + +1. **Image:** Generate a sample first. Check `prompt_extend: true` (default) — DashScope rewrites your prompt for better results. Disable if you need literal prompt adherence. +2. **TTS:** Generate a 10-15 second sample before full narration. Approve voice and pacing before committing to full generation. +3. **ASR:** Audio must be at a **publicly accessible URL**. Upload to any public host (S3, etc.) first. Local paths are rejected with a clear error. +4. **Subtitles:** Build from `result.data["words"]` — each word has `begin_time_seconds` and `end_time_seconds`. Group words into caption phrases by language semantics, not fixed character count. + +## Parameters + +### Image (`dashscope_image`) +- `prompt` (required): text prompt +- `model`: default `qwen-image-2.0-pro` +- `size`: default `"1024*1024"` — **asterisk separator, not "x"** +- `n`: 1-6 images +- `negative_prompt`: things to avoid (max 500 chars) +- `prompt_extend`: default `true` — auto-rewrite prompt for better results +- `watermark`: default `false` +- `seed`: for reproducibility + +### TTS (`dashscope_tts`) +- `text` (required): text to synthesize (max 600 chars for qwen3-tts-flash) +- `model`: default `qwen3-tts-flash` +- `voice`: default `"Cherry"` — other voices: `"Ethan"`, `"Chelsie"`, etc. +- `language_type`: default `"Auto"` — `"Chinese"`, `"English"`, `"Japanese"`, `"Korean"` +- `instructions`: natural language delivery instructions (only for `qwen3-tts-instruct-flash`) + +### ASR (`dashscope_asr`) +- `audio_url` (required): **must be publicly accessible URL** +- `model`: `qwen3-asr-flash-filetrans` (only model that supports word timestamps) +- `language_hints`: default `["zh", "en"]` +- `enable_words`: default `true` — required for word-level timestamps +- `poll_interval_seconds`: default `5.0` +- `timeout_seconds`: default `300` + +## Troubleshooting + +- **Image size error:** Use `"W*H"` with asterisk, not `"WxH"`. Example: `"2048*2048"`. +- **TTS no audio URL:** Check `output.audio.url` — if empty, the model name or voice may be wrong. +- **ASR "file not accessible":** `audio_url` must be publicly reachable. DashScope servers fetch the file; local paths and auth-gated URLs don't work. +- **ASR poll timeout:** Increase `timeout_seconds` (default 300). Long audio files take longer to transcribe. +- **ASR no word timestamps:** Ensure `enable_words: true` and model is `qwen3-asr-flash-filetrans` (not the sync `qwen3-asr-flash`). +- **Auth error (401):** Verify `DASHSCOPE_API_KEY` is set. Use `Authorization: Bearer $KEY` header. + +## Safety + +Never print or write the API key to logs, metadata, patches, or project artifacts. `.env.example` should contain only empty variable names. The tool's `_safe_error()` method redacts the key from error messages. diff --git a/.claude/commands/backlot.md b/.claude/commands/backlot.md new file mode 100644 index 00000000..be09edf7 --- /dev/null +++ b/.claude/commands/backlot.md @@ -0,0 +1,15 @@ +--- +description: Open the Backlot living storyboard — the browser board that shows pipeline stages, script, scene plan, and generated assets live as a production runs. +argument-hint: [project-id (optional — defaults to the current/most recent project)] +--- + +Open the Backlot board for the requested project: + +```bash +python -m backlot open $ARGUMENTS +``` + +- No argument → open the library view (all projects): `python -m backlot open` +- The command is idempotent: it starts the Backlot server if it isn't running, then opens the browser at the project's board. +- If the command fails, report it and continue with whatever the user asked — the board is an observer, never a blocker. +- The board derives everything from disk (`projects//` checkpoints, artifacts, assets, events). You never update the UI manually; keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md` and the board stays honest too. diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..0bb630c2 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "backlot", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "backlot", "serve", "--port", "4750"], + "port": 4750 + }, + { + "name": "mockups", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "4788", "--bind", "127.0.0.1"], + "port": 4788 + } + ] +} diff --git a/.codex/prompts/backlot.md b/.codex/prompts/backlot.md new file mode 100644 index 00000000..9af8f2be --- /dev/null +++ b/.codex/prompts/backlot.md @@ -0,0 +1,12 @@ +# /backlot — open the living storyboard + +Open the Backlot board (browser UI showing pipeline stages, script, scene plan, and generated assets live) for the requested project: + +```bash +python -m backlot open +``` + +- No project id → open the library view: `python -m backlot open` +- Idempotent: starts the Backlot server if needed, then opens the browser at the project's board. +- If it fails, report and continue — the board is an observer, never a blocker. +- The board derives all state from `projects//` on disk; never update the UI manually. Keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md`. diff --git a/.cursor/commands/backlot.md b/.cursor/commands/backlot.md new file mode 100644 index 00000000..9af8f2be --- /dev/null +++ b/.cursor/commands/backlot.md @@ -0,0 +1,12 @@ +# /backlot — open the living storyboard + +Open the Backlot board (browser UI showing pipeline stages, script, scene plan, and generated assets live) for the requested project: + +```bash +python -m backlot open +``` + +- No project id → open the library view: `python -m backlot open` +- Idempotent: starts the Backlot server if needed, then opens the browser at the project's board. +- If it fails, report and continue — the board is an observer, never a blocker. +- The board derives all state from `projects//` on disk; never update the UI manually. Keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md`. diff --git a/.env.example b/.env.example index f5233118..73e7122b 100644 --- a/.env.example +++ b/.env.example @@ -16,12 +16,16 @@ GOOGLE_CLOUD_LOCATION= # Vertex AI region, default us-central1 # --- Voice --- ELEVENLABS_API_KEY= # TTS narration, music generation, sound effects -OPENAI_API_KEY= # OpenAI TTS fallback and DALL-E image generation +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 # 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/ + # --- Music --- SUNO_API_KEY= # Suno AI music generation (full songs, instrumentals, any genre) diff --git a/.github/prompts/backlot.prompt.md b/.github/prompts/backlot.prompt.md new file mode 100644 index 00000000..9af8f2be --- /dev/null +++ b/.github/prompts/backlot.prompt.md @@ -0,0 +1,12 @@ +# /backlot — open the living storyboard + +Open the Backlot board (browser UI showing pipeline stages, script, scene plan, and generated assets live) for the requested project: + +```bash +python -m backlot open +``` + +- No project id → open the library view: `python -m backlot open` +- Idempotent: starts the Backlot server if needed, then opens the browser at the project's board. +- If it fails, report and continue — the board is an observer, never a blocker. +- The board derives all state from `projects//` on disk; never update the UI manually. Keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md`. diff --git a/.gitignore b/.gitignore index 7a7c27a2..52051e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ remotion-composer/public/demo-props/caption-burn-* venv/ .venv/ + +# Backlot local cache (thumbnails) +.backlot/ diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 085a52d3..1abfd8e5 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -114,6 +114,12 @@ The agent must ask the user before changing any major production choice, includi Minor prompt refinements inside an already approved provider/model path do not require separate approval unless they materially change the creative direction. +### Re-log Changed Decisions (Binding) + +The `decision_log` is the board's Decisions rail and the run's audit trail. It is **append-only history, not a scratchpad.** When a choice you already logged changes mid-run — the user swaps the voice, you switch provider/model/runtime/music, or a fallback overrides an earlier pick — you MUST **append a new `decision_log` entry** for the new choice, reusing the **same `category` AND the same `subject`** (e.g. `category: "voice_selection"`, `subject: "Narration TTS provider"`), with the superseded option moved into `options_considered` and `rejected_because` noting it was changed. + +Editing only a downstream artifact (the `asset_manifest`, a prop) while leaving the old decision in the log is a defect: the board keeps showing the stale choice (e.g. `voice → openai_onyx` after the user moved to Chirp3). The board identifies a decision by its **(category, subject) pair** and renders the latest entry for that pair as current (tagged "revised") — so the fix is to append the new entry with an identical `subject`, never to silently mutate the old one or reword the subject (a reworded subject reads as a different decision and both will show). Keeping distinct decisions in one category (e.g. TTS vs image `provider_selection`) is exactly why the pair, not the category alone, is the key. This applies at every stage, not just `idea`. + ### Present Both Composition Runtimes (HARD RULE) When both Remotion and HyperFrames are available on the machine (check `video_compose.get_info()["render_engines"]`), the agent **MUST present both options to the user** before locking `render_runtime` at the proposal stage. The agent MAY recommend one with rationale — but silently picking a "default" is forbidden even when the pipeline manifest or a director skill suggests one. @@ -137,7 +143,7 @@ Orthogonal to *runtime* is *authoring mode*: **how** the composition is built. P - **Templated** — assemble the stock `cut.type` scene-types (`text_card`, `stat_card`, `bar_chart`, …) into the `Explainer`/`CinematicRenderer` compositions. Fast, cheap, reliable — and the reason most videos look alike. Right for batch output, localization variants, quick drafts, and low-stakes internal clips. - **Atelier** — **hand-author the composition from scratch**: bespoke scenes, a one-off theme, and motion written for this piece, rendered via `composition_mode: "atelier"` (see `video_compose` → `_render_via_atelier`). No reusable creative components; a fresh visual language every time. -**Default to atelier for hero work** — marketing, launches, brand pieces, any single-deliverable explainer that must impress. The deciding rule: *reuse engine knowledge, never creative components.* In atelier mode the stock scene-type catalog, `hyperframes-registry` blocks, fixtures, and finished components are **off-limits** — they are frozen looks that reintroduce sameness. Before building, route through **`skills/meta/bespoke-composition.md`**, which sequences: art direction (`visual-style`) → motion principles (Disney 12 via `framer-motion`/`lottie-bodymovin`) → engine mechanics (`remotion-best-practices` + the stock components read *only as a mechanics codex*) → render via the atelier path. Close with a **distinctness review**: *could this be any other product's video? does it reuse a look I've made before?* — the inverse of "does it match the reference." Atelier costs more tokens and iteration than templated; say so at proposal so the user opts in knowingly. +**Default to atelier for hero work** — marketing, launches, brand pieces, any single-deliverable explainer that must impress. The deciding rule: *reuse engine knowledge, never creative components.* In atelier mode the stock scene-type catalog, `hyperframes-registry` blocks, fixtures, and finished components are **off-limits** — they are frozen looks that reintroduce sameness. Before building, route through **`skills/meta/taste-direction.md`** to set the design read and taste dials, then **`skills/meta/bespoke-composition.md`**, which sequences: art direction (`visual-style`) → motion principles (Disney 12 via `framer-motion`/`lottie-bodymovin`) → engine mechanics (`remotion-best-practices` + the stock components read *only as a mechanics codex*) → render via the atelier path. Close with a **distinctness review**: *could this be any other product's video? does it reuse a look I've made before?* — the inverse of "does it match the reference." Atelier costs more tokens and iteration than templated; say so at proposal so the user opts in knowingly. ### Escalate Blockers Explicitly @@ -213,7 +219,14 @@ projects// **Naming convention**: Use kebab-case derived from the video title (e.g., `hidden-math-of-nature`, `how-music-rewires-brain`). -Create the project directory at pipeline initialization, before any stage runs. All tools and agents should write outputs to these paths — never to the repo root or ad-hoc locations. +At pipeline initialization, before any stage runs: + +1. **Initialize the workspace**: `python -c "from lib.checkpoint import init_project; init_project('', title='', pipeline_type='<pipeline>')"` — creates the layout above and writes `project.json` (the marker the Backlot board reads). +2. **Open the board**: run `python -m backlot open <project-id>`. This starts the Backlot server if needed and opens the user's browser at the project's live board. If the command fails, continue the production — the board is an observer, never a blocker. This is the agent's ONLY board duty; the board derives everything else from disk. + +All tools and agents must write outputs to these paths — **always pass an explicit `output_path` under `projects/<project-id>/`**. Assets written to the repo root, cwd, or temp dirs are invisible to the user's board and violate the workspace contract. + +**This applies to atelier and HyperFrames-skill runs too**: hand-authored compositions still write the canonical artifacts they have (script or beats-plan, scene_plan-equivalent, asset manifest) plus checkpoints into `projects/<project-id>/`. The board is runtime-agnostic; only runs that skip the artifacts get a degraded board. ## Music Library @@ -489,7 +502,7 @@ Three selector tools abstract multi-provider capabilities. **Selectors auto-disc | Selector | Routes to | How it discovers | |----------|-----------|-----------------| | `tts_selector` | All tools with `capability="tts"` (ElevenLabs, Google TTS, OpenAI, Piper) | `registry.get_by_capability("tts")` | -| `image_selector` | All tools with `capability="image_generation"` (FLUX, Google Imagen, DALL-E, Recraft, etc.) | `registry.get_by_capability("image_generation")` | +| `image_selector` | All tools with `capability="image_generation"` (FLUX, Google Imagen, GPT Image, Recraft, etc.) | `registry.get_by_capability("image_generation")` | | `video_selector` | All tools with `capability="video_generation"` | `registry.get_by_capability("video_generation")` | Selectors route based on: user preference > availability > discovery order. They adapt input schemas between providers transparently. @@ -568,11 +581,11 @@ The reviewer is a meta skill (`skills/meta/reviewer.md`) — advisory, never dir The checkpoint protocol meta skill (`skills/meta/checkpoint-protocol.md`) teaches the agent when to pause: -- Read `human_approval_default` from the pipeline manifest per stage -- Creative stages (`idea`, `script`, `scene_plan`) typically require approval -- Technical stages (`assets`, `edit`, `compose`) typically auto-proceed -- When approval is required: present artifact summary, review findings, and cost snapshot -- Wait for human to approve, request revision, or abort +- Read `human_approval_default` from the pipeline manifest per stage. **The manifest value is binding** — never re-judge it. `lib/checkpoint.py` enforces this: a gated stage cannot be written `completed` without `human_approved=True`. +- Typical gated stages: `idea`/`proposal`, `script`, `scene_plan`, **`assets`** (review the generated assets scene-by-scene — the Backlot board's filmstrip — before compose locks them in), and `publish` where the pipeline has one. Most pipelines auto-proceed on `edit` and `compose`, but not all (documentary-montage gates `edit`) — the manifest you loaded is the only authority. +- When approval is required: write the checkpoint as `awaiting_human`, present artifact summary, review findings, and cost snapshot — then **END YOUR TURN**. Doing further pipeline work in the same response is a gate violation. +- **Approval is per-gate.** An early "go ahead" never covers later gates; explicit full-run pre-authorization must be recorded as a `decision_log` entry (`category: "approval_policy"`) to count. +- Wait for human to approve, request revision, or abort. ## Communication Protocol @@ -592,9 +605,12 @@ Primary files: Checkpoint rules: -- Checkpoints live at `pipelines/<project_id>/checkpoint_<stage>.json`. +- Checkpoints live at `projects/<project_id>/checkpoint_<stage>.json` (the project workspace — this is what the Backlot board watches). - `status` may be `completed`, `failed`, `awaiting_human`, or `in_progress`. +- Write an `in_progress` checkpoint on entering each stage; during `assets`/`compose`, refresh `metadata.partial_progress` after each completed scene/asset unit — this powers live progress on the board. - `completed` and `awaiting_human` checkpoints must include the canonical artifact. +- A gated stage (`human_approval_default: true`) can only be written `completed` with `human_approved=True` — the writer raises a GATE VIOLATION otherwise. +- Superseded checkpoints are archived automatically to `projects/<project_id>/history/` — stage re-runs never destroy run history. - Invalid checkpoints or invalid canonical artifacts are contract violations and should fail fast. Pipeline manifest rules: @@ -614,10 +630,13 @@ Tool rules: | Playbook | Best For | |----------|----------| | `clean-professional` | Corporate, educational, SaaS | +| `premium-minimalist` | Investor updates, expert explainers, product narratives | | `flat-motion-graphics` | Social media, TikTok, startups | | `minimalist-diagram` | Technical deep-dives, architecture | | `ink-sketch` (Ink Theater) | Hand-drawn ink-on-white doodle animation; a character that draws itself, walks, dances; contraption explainers | +For custom, atelier, brand, launch, or hero work, read `skills/meta/taste-direction.md` before choosing a playbook. Carry its `taste_profile` into the proposal so later stages can preserve the design read, visual variance, motion intensity, information density, reference strategy, and anti-patterns. + ### Hand-drawn "doodle" animation → Ink Theater / Ink Puppet For any brief that wants a **hand-drawn ink doodle** look — "a sketch that comes to life", "a pencil / stick figure that walks or dances", "a little character that acts out the idea", whiteboard-doodle explainers — use the **Ink Theater** engine + **Ink Puppet** mocap system (`skills/creative/ink-theater.md`, `ink-theater/README.md`). It is a **style + reusable engine, not a new pipeline**: illustration / contraption pieces run on the `animation` pipeline; a mocap character (draws itself → walks / dances / waves via `InkPuppet.choreograph([...])`) runs on `character-animation`. Cross-tool entry points: **`/ink-art`** (create a vector doodle from scratch) and **`/animated-drawing`** (animate a *supplied* drawing with mocap — raster; `skills/creative/animated-drawing.md`). Never hand-tune character motion — the agent only chooses named mocap clips. diff --git a/README.md b/README.md index 168f4ca2..ed9b2a91 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,36 @@ Works with **Claude Code, Cursor, Copilot, Windsurf, Codex** — any AI coding a --- +## Watch It Happen — The Backlot Living Storyboard + +Chat tells you what the agent *said*. **Backlot shows you what the production is actually doing** — a local board that fills itself in as the pipeline runs. Stages light up, the script lands as a screenplay page, scene cards shimmer while assets generate, and every provider decision and dollar spent is on the wall. + +When a production starts, the agent opens it for you automatically. No setup, no reporting — the board derives everything from the project files the pipeline already writes. + +<p align="center"><img src="docs/images/backlot/board-live.png" alt="Backlot live board — assets generating" width="920"></p> + +**The storyboard is now a real approval gate.** Asset generation pauses on a scene-by-scene contact sheet — takes, prompts, per-asset cost, quality scores — so you approve the visuals *before* the render, not after it's too late: + +<p align="center"><img src="docs/images/backlot/storyboard.png" alt="Backlot storyboard — filmstrip with takes and renders" width="920"></p> + +Creative gates hold until you answer. The board shows what's waiting and why; you reply in chat: + +<p align="center"><img src="docs/images/backlot/script-gate.png" alt="Backlot script gate — awaiting approval" width="920"></p> + +Every production on your machine, live-first, in the library: + +<p align="center"><img src="docs/images/backlot/library.png" alt="Backlot library" width="920"></p> + +```bash +python -m backlot open # the library — every project on disk +python -m backlot open <project-id> # one production's live board +python scripts/backlot_simulate_run.py # no production yet? watch a simulated one live +``` + +And when a run is done, hit **▶ REPLAY RUN** — the whole production replays from its timestamps, scrubbable end to end. See [`backlot/README.md`](backlot/README.md) for how it works. + +--- + ## Quick Start ### Prerequisites @@ -183,7 +213,7 @@ SUNO_API_KEY=your-key # Full songs, instrumentals, any genre # Voice & images: ELEVENLABS_API_KEY=your-key # Premium TTS, AI music, sound effects -OPENAI_API_KEY=your-key # OpenAI TTS, DALL-E 3 images +OPENAI_API_KEY=your-key # OpenAI TTS, GPT Image 2 images XAI_API_KEY=your-key # xAI Grok image edits/generation + Grok video generation GOOGLE_API_KEY=your-key # Google Imagen images, Google TTS (700+ voices) @@ -447,7 +477,7 @@ Each tool declares which Layer 3 skills it relies on. The agent reads Layer 1 to | **FLUX** | Cloud API | State-of-the-art quality | | **Google Imagen** | Cloud API | Imagen 4 — high-quality, multiple aspect ratios | | **Grok Imagine Image** | Cloud API | Strong image edits, style transfer, and multi-image compositing | -| **DALL-E 3** | Cloud API | OpenAI's image model | +| **GPT Image 2** | Cloud API | OpenAI's image model | | **Recraft** | Cloud API | Design-focused generation | | **Local Diffusion** | Local GPU | Stable Diffusion, free | | **Pexels** | Stock | Free stock images | @@ -568,6 +598,7 @@ OpenMontage treats video production like real engineering — with quality gates ### Quality Gates +- **Human approval gates are enforced, not suggested** — proposal, script, scene plan, generated assets, and publish all pause for your sign-off. The checkpoint writer rejects a "completed" gated stage without recorded approval, and every superseded checkpoint is archived so the audit trail (including gate transitions) survives revisions. Review happens visually on the [Backlot board](#watch-it-happen--the-backlot-living-storyboard). - **Pre-compose validation** — blocks render if the delivery promise is violated (e.g. "motion-led" video with 80% still images), slideshow risk score is critical, or renderer family is missing. Catches broken plans before wasting GPU time. - **Post-render self-review** — after every render, the runtime runs ffprobe validation, extracts frames at 4 positions to check for black frames and broken overlays, analyzes audio levels for silence and clipping, verifies the delivery promise was honored, and checks subtitle presence. If the review fails, the video is not presented. - **Slideshow risk scoring** — 6-dimension analysis (repetition, decorative visuals, weak motion, shot intent, typography overreliance, unsupported cinematic claims) prevents "animated PowerPoint" outputs. diff --git a/README_zh-CN.md b/README_zh-CN.md index e962b0c3..ca663047 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -177,7 +177,7 @@ SUNO_API_KEY=your-key # 完整的歌曲、伴奏,涵盖任何流派 # 语音与图像: ELEVENLABS_API_KEY=your-key # 顶级 TTS、AI 音乐、音效 -OPENAI_API_KEY=your-key # OpenAI TTS、DALL-E 3 图像 +OPENAI_API_KEY=your-key # OpenAI TTS、GPT Image 2 图像 XAI_API_KEY=your-key # xAI Grok 图像编辑/生成 + Grok 视频生成 GOOGLE_API_KEY=your-key # Google Imagen 图像、Google TTS(700+ 种声音) @@ -441,7 +441,7 @@ OpenMontage/ | **FLUX** | 云端 API | 业界顶尖质量 | | **Google Imagen** | 云端 API | Imagen 4 — 高质量、多种长宽比 | | **Grok Imagine Image** | 云端 API | 强大的图像编辑、风格转换和多图合成 | -| **DALL-E 3** | 云端 API | OpenAI 的图像模型 | +| **GPT Image 2** | 云端 API | OpenAI 的图像模型 | | **Recraft** | 云端 API | 专注于设计的生成 | | **Local Diffusion** | 本地 GPU | Stable Diffusion,免费 | | **Pexels** | 素材库 | 免费的库存图片 | diff --git a/backlot/README.md b/backlot/README.md new file mode 100644 index 00000000..6ce7edcb --- /dev/null +++ b/backlot/README.md @@ -0,0 +1,42 @@ +# Backlot — the living storyboard + +A read-only local board that shows a production happening: pipeline stages +lighting up, the script as a screenplay page, the scene plan as a filmstrip +that fills in as assets generate, decisions, spend, and activity — all +derived from what the pipeline already writes to `projects/<id>/`. + +```bash +python -m backlot open <project-id> # start server if needed + open browser +python -m backlot open # library view (all projects) +python -m backlot serve --port 4750 # run the server in the foreground +``` + +## How it stays live + +No agent involvement. A `watchfiles` watcher on `projects/` publishes change +notifications over SSE; the browser refetches board state. State sources: + +| Board element | Disk source | +|---|---| +| identity / rail order | `project.json` + `pipeline_defs/<type>.yaml` | +| stage states, gates, versions | `checkpoint_<stage>.json` + `history/` | +| script card / modal | `artifacts/script.json` | +| filmstrip cards | `scene_plan × script × asset_manifest` join | +| generating shimmer, activity | `events.jsonl` (written by `BaseTool` instrumentation) | +| cost meter | checkpoint `cost_snapshot` | +| renders | `renders/*.mp4` (+ root-level mp4 heuristic) | + +Projects without checkpoints degrade gracefully to a "what the watcher +found" view — media, snapshots, renders. + +**Replay**: a completed run can be scrubbed end-to-end (▶ REPLAY RUN on the +board) — reconstructed from checkpoint history and event timestamps. + +Try it without a real production: + +```bash +python scripts/backlot_simulate_run.py # live demo run (~1 min) +python -m backlot open backlot-demo-run +``` + +Design doc: `internal/design/LIVING_STORYBOARD.md`. diff --git a/backlot/__init__.py b/backlot/__init__.py new file mode 100644 index 00000000..1d0c582a --- /dev/null +++ b/backlot/__init__.py @@ -0,0 +1,16 @@ +"""Backlot — the living storyboard. + +A read-only, disk-derived production board for OpenMontage. A small local web +server watches ``projects/`` and renders each production's pipeline stages, +script, scene plan, generated assets, decisions, cost, and activity — live. + +Design contract (see internal/design/LIVING_STORYBOARD.md): +- Observation, not reporting: all state derives from files the pipeline + already writes. Agents never update the UI. +- Never block, never break: malformed or missing state degrades gracefully. +- The agent's only duty: ``python -m backlot open <project>`` at pipeline init. +""" + +__version__ = "0.1.0" + +DEFAULT_PORT = 4750 diff --git a/backlot/__main__.py b/backlot/__main__.py new file mode 100644 index 00000000..6004ef3d --- /dev/null +++ b/backlot/__main__.py @@ -0,0 +1,109 @@ +"""Backlot CLI. + + python -m backlot open [project-id] # start server if needed, open browser + python -m backlot serve [--port N] # run the server in the foreground + +``open`` is idempotent and non-fatal by design: agents call it at pipeline +initialization and must continue the production even if it fails. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +import urllib.request +import webbrowser + +from backlot import DEFAULT_PORT + + +def _port() -> int: + try: + return int(os.environ.get("BACKLOT_PORT", DEFAULT_PORT)) + except ValueError: + return DEFAULT_PORT + + +def _server_alive(port: int) -> bool: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1.5) as resp: + return resp.status == 200 + except Exception: + return False + + +def _spawn_server(port: int) -> None: + """Start the server as a detached background process.""" + cmd = [sys.executable, "-m", "backlot", "serve", "--port", str(port)] + kwargs: dict = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "stdin": subprocess.DEVNULL, + } + if os.name == "nt": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) + ) + else: + kwargs["start_new_session"] = True + subprocess.Popen(cmd, **kwargs) + + +def cmd_open(project_id: str | None) -> int: + port = _port() + if not _server_alive(port): + try: + _spawn_server(port) + except Exception as exc: + print(f"backlot: could not start server ({exc}) — continuing without the board") + return 1 + deadline = time.time() + 15 + while time.time() < deadline: + if _server_alive(port): + break + time.sleep(0.4) + else: + print("backlot: server did not come up in time — continuing without the board") + return 1 + url = f"http://127.0.0.1:{port}/" + if project_id: + url = f"http://127.0.0.1:{port}/p/{project_id}" + try: + webbrowser.open(url) + except Exception: + pass + print(f"backlot: {url}") + return 0 + + +def cmd_serve(port: int) -> int: + import uvicorn + + uvicorn.run("backlot.server:app", host="127.0.0.1", port=port, log_level="warning") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="backlot", description=__doc__) + sub = parser.add_subparsers(dest="command") + + p_open = sub.add_parser("open", help="open the board in the browser (starts server if needed)") + p_open.add_argument("project_id", nargs="?", default=None) + + p_serve = sub.add_parser("serve", help="run the Backlot server in the foreground") + p_serve.add_argument("--port", type=int, default=_port()) + + args = parser.parse_args(argv) + if args.command == "open": + return cmd_open(args.project_id) + if args.command == "serve": + return cmd_serve(args.port) + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backlot/server.py b/backlot/server.py new file mode 100644 index 00000000..f2220d14 --- /dev/null +++ b/backlot/server.py @@ -0,0 +1,341 @@ +"""Backlot server — FastAPI app: board state API, SSE change feed, media. + +The watcher observes ``projects/`` with watchfiles; on any change it bumps a +per-project version and wakes SSE subscribers, who tell the browser to +refetch state. The server never writes to project directories. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state, summarize_project + +UI_DIR = Path(__file__).resolve().parent / "ui" +THUMB_CACHE_DIR = REPO_ROOT / ".backlot" / "thumbs" +THUMB_WIDTHS = (320, 640, 960) + +# Paths inside a project whose changes are pure noise for the board. +_IGNORE_PARTS = {"node_modules", ".git", "__pycache__", ".cache"} + +SSE_HEARTBEAT_SECONDS = 15 + + +class ChangeHub: + """Fan-out of project-change notifications to SSE subscribers. + + Subscriptions are filtered: a board subscribed to one project only ever + receives that project's ids, so unrelated-project bursts can't flood its + queue and starve out the one notification it actually needs. + """ + + def __init__(self) -> None: + self._subscribers: dict[asyncio.Queue, Optional[str]] = {} + + def subscribe(self, project_id: Optional[str] = None) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=64) + self._subscribers[q] = project_id + return q + + def unsubscribe(self, q: asyncio.Queue) -> None: + self._subscribers.pop(q, None) + + def publish(self, project_id: str) -> None: + for q, only in list(self._subscribers.items()): + if only is not None and only != project_id: + continue + try: + q.put_nowait(project_id) + except asyncio.QueueFull: + # Queue holds only THIS subscriber's relevant ids, so a full + # queue already guarantees a pending wake-up → safe to drop. + pass + + +hub = ChangeHub() + +# Library summaries are expensive to derive (full state parse per project); +# cache per project and invalidate from the watcher. +_summary_cache: dict[str, dict] = {} + + +def _invalidate_summary(project_id: str) -> None: + _summary_cache.pop(project_id, None) + + +def _cached_summaries() -> list[dict]: + if not PROJECTS_DIR.is_dir(): + return [] + summaries = [] + for entry in sorted(PROJECTS_DIR.iterdir()): + if not entry.is_dir() or entry.name.startswith(("_", ".")): + continue + cached = _summary_cache.get(entry.name) + if cached is None: + try: + cached = summarize_project(entry) + except Exception: + cached = { + "project_id": entry.name, "title": entry.name, + "pipeline_type": "unknown", "has_pipeline_state": False, + "poster": None, "live": False, "last_activity": 0, + "active_stage": None, "awaiting_human": False, + "stage_states": [], "completed_count": 0, + "render_count": 0, "scene_count": 0, "error": "unreadable", + } + _summary_cache[entry.name] = cached + summaries.append(cached) + summaries.sort(key=lambda s: (not s["live"], -(s["last_activity"] or 0))) + return summaries + + +# Watch-loop hot path: pure string comparison, no per-path filesystem calls +# (change batches can be thousands of paths during a render). +import os as _os + +_PROJECTS_ROOT_STR = _os.path.normcase(str(PROJECTS_DIR.resolve())) + + +def _project_of_change(path_str: str) -> Optional[str]: + """Map a changed filesystem path to a project id (None = irrelevant).""" + norm = _os.path.normcase(_os.path.normpath(path_str)) + if not norm.startswith(_PROJECTS_ROOT_STR): + return None + rel = norm[len(_PROJECTS_ROOT_STR):].lstrip("\\/") + if not rel: + return None + parts = rel.replace("\\", "/").split("/") + if _IGNORE_PARTS.intersection(parts): + return None + return parts[0] + + +async def _watch_projects() -> None: + """Background task: watch projects/ and publish debounced changes.""" + try: + from watchfiles import awatch + except ImportError: + return # watcher unavailable → board still works via manual refresh + if not PROJECTS_DIR.is_dir(): + return + async for changes in awatch(PROJECTS_DIR, recursive=True, step=400): + touched: set[str] = set() + for _change, path_str in changes: + pid = _project_of_change(path_str) + if pid: + touched.add(pid) + for pid in touched: + _invalidate_summary(pid) + hub.publish(pid) + + +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() + + # ---- API ---------------------------------------------------------- + + @app.get("/api/health") + async def health() -> dict: + return {"ok": True, "app": "backlot"} + + @app.get("/api/projects") + async def projects() -> list: + return await asyncio.to_thread(_cached_summaries) + + @app.get("/api/project/{project_id}/state") + async def project_state(project_id: str) -> dict: + project_dir = _safe_project_dir(project_id) + return await asyncio.to_thread(load_board_state, project_dir) + + @app.get("/api/project/{project_id}/events") + async def project_events(project_id: str, request: Request) -> StreamingResponse: + _safe_project_dir(project_id) # 404 early for unknown projects + + async def stream(): + q = hub.subscribe(project_id) + try: + yield _sse({"type": "hello", "project_id": project_id}) + while True: + if await request.is_disconnected(): + return + try: + await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS) + except asyncio.TimeoutError: + yield _sse({"type": "heartbeat", "ts": time.time()}) + continue + # Coalesce bursts: drain anything else queued. + while not q.empty(): + try: + q.get_nowait() + except asyncio.QueueEmpty: + break + yield _sse({"type": "change", "project_id": project_id}) + finally: + hub.unsubscribe(q) + + return StreamingResponse(stream(), media_type="text/event-stream", headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }) + + @app.get("/api/library/events") + async def library_events(request: Request) -> StreamingResponse: + async def stream(): + q = hub.subscribe() + try: + yield _sse({"type": "hello"}) + while True: + if await request.is_disconnected(): + return + try: + changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS) + except asyncio.TimeoutError: + yield _sse({"type": "heartbeat", "ts": time.time()}) + continue + while not q.empty(): + try: + q.get_nowait() + except asyncio.QueueEmpty: + break + yield _sse({"type": "change", "project_id": changed}) + finally: + hub.unsubscribe(q) + + return StreamingResponse(stream(), media_type="text/event-stream", headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }) + + # ---- Thumbnails (downscaled, cached on disk) ------------------------ + + @app.get("/thumb/{project_id}/{file_path:path}") + async def thumb(project_id: str, file_path: str, w: int = 640) -> FileResponse: + project_dir = _safe_project_dir(project_id) + target = (project_dir / file_path).resolve() + try: + target.relative_to(project_dir.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="path escapes project") + if not target.is_file(): + raise HTTPException(status_code=404, detail="media not found") + width = min(THUMB_WIDTHS, key=lambda x: abs(x - w)) + cached = await asyncio.to_thread(_thumbnail_for, target, width) + if cached is None: + # Never fall back to raw video bytes for an <img> consumer (F-03); + # non-thumbable images are safe to serve as-is. + if target.suffix.lower() in {".mp4", ".webm", ".mov"}: + raise HTTPException(status_code=404, detail="no poster frame available") + return FileResponse(target) + return FileResponse(cached, media_type="image/jpeg") + + # ---- Media (range requests handled by FileResponse) --------------- + + @app.get("/media/{project_id}/{file_path:path}") + async def media(project_id: str, file_path: str) -> FileResponse: + project_dir = _safe_project_dir(project_id) + target = (project_dir / file_path).resolve() + try: + target.relative_to(project_dir.resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="path escapes project") + if not target.is_file(): + raise HTTPException(status_code=404, detail="media not found") + return FileResponse(target) + + # ---- UI ------------------------------------------------------------ + + @app.get("/p/{project_id}") + async def board_page(project_id: str) -> FileResponse: + return FileResponse(UI_DIR / "board.html") + + @app.get("/p/{project_path:path}") + async def board_page_path(project_path: str) -> FileResponse: + return FileResponse(UI_DIR / "board.html") + + @app.get("/") + async def library_page() -> FileResponse: + return FileResponse(UI_DIR / "index.html") + + if UI_DIR.is_dir(): + app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui") + + return app + + +def _safe_project_dir(project_id: str) -> Path: + # ':' rejects Windows drive-relative ids like "C:" (PROJECTS_DIR / "C:" + # collapses back to PROJECTS_DIR itself). + if any(c in project_id for c in "/\\:") or project_id in (".", ".."): + raise HTTPException(status_code=400, detail="invalid project id") + project_dir = PROJECTS_DIR / project_id + if not project_dir.is_dir(): + raise HTTPException(status_code=404, detail=f"unknown project: {project_id}") + return project_dir + + +def _sse(payload: dict) -> str: + return f"data: {json.dumps(payload)}\n\n" + + +def _thumbnail_for(source: Path, width: int) -> Optional[Path]: + """Downscale an image (or extract a video poster frame) to a cached JPEG.""" + suffix = source.suffix.lower() + is_image = suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"} + is_video = suffix in {".mp4", ".webm", ".mov"} + if not (is_image or is_video): + return None + try: + import hashlib + stat = source.stat() + key = hashlib.sha1( + f"{source}|{stat.st_mtime_ns}|{stat.st_size}|{width}".encode() + ).hexdigest()[:20] + cached = THUMB_CACHE_DIR / f"{key}.jpg" + if cached.is_file(): + return cached + THUMB_CACHE_DIR.mkdir(parents=True, exist_ok=True) + # Unique temp per request — concurrent misses for the same source + # must not write (and replace from) the same temp file. + import uuid + tmp = THUMB_CACHE_DIR / f"{key}.{uuid.uuid4().hex[:8]}.tmp.jpg" + if is_video: + import subprocess + result = subprocess.run( + ["ffmpeg", "-y", "-loglevel", "error", "-ss", "1.5", + "-i", str(source), "-frames:v", "1", + "-vf", f"scale={width}:-2", str(tmp)], + capture_output=True, timeout=30, + ) + if result.returncode != 0 or not tmp.is_file(): + return None + else: + from PIL import Image + with Image.open(source) as img: + img = img.convert("RGB") + img.thumbnail((width, width * 3)) + img.save(tmp, "JPEG", quality=82) + tmp.replace(cached) + return cached + except Exception: + return None + + +app = create_app() diff --git a/backlot/state.py b/backlot/state.py new file mode 100644 index 00000000..9ce50f7a --- /dev/null +++ b/backlot/state.py @@ -0,0 +1,703 @@ +"""BoardState derivation — turn a project directory into renderable state. + +Everything here is read-only and defensive: a malformed JSON file, a missing +artifact, or a half-written checkpoint must degrade the board, never crash it +(design principle: "never block, never break"). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Optional + +from lib.events import read_events +from lib.paths import PROJECTS_DIR, REPO_ROOT # single source of truth (env-overridable) + +MEDIA_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"} +MEDIA_VIDEO_EXT = {".mp4", ".webm", ".mov"} +MEDIA_AUDIO_EXT = {".mp3", ".wav", ".m4a", ".ogg"} + +# Directories inside a project we never scan for media (build noise). +SCAN_EXCLUDE = {"node_modules", ".git", "__pycache__", "history", ".cache"} + +# Stages every pipeline shares (fallback rail when the manifest is unknown). +FALLBACK_STAGES = [ + "research", "proposal", "idea", "script", "scene_plan", + "assets", "edit", "compose", "publish", +] + +# How long (seconds) without filesystem activity before a board reads "idle". +LIVE_WINDOW_SECONDS = 5 * 60 + +# An in_progress stage with no filesystem activity for this long is flagged +# as possibly stalled (F-05: a wedged agent must be visible, not silent — +# heartbeat checkpoints and tool events both reset the clock). +STALL_WINDOW_SECONDS = 10 * 60 + + +def _read_json(path: Path) -> Optional[dict]: + """Read a JSON file, returning None on any failure.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError, UnicodeError): + return None + + +def _rel(project_dir: Path, path: Path) -> str: + """Project-relative POSIX path for media URLs.""" + try: + return path.resolve().relative_to(Path(project_dir).resolve()).as_posix() + except (ValueError, OSError): + return path.name + + +# --------------------------------------------------------------------------- +# Pipeline / stages +# --------------------------------------------------------------------------- + +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": + try: + from lib.pipeline_loader import load_pipeline + manifest = load_pipeline(pipeline_type) + stages = [ + { + "name": s["name"], + "gated": bool(s.get("human_approval_default", False)), + } + for s in manifest.get("stages", []) + if isinstance(s, dict) and s.get("name") + ] + if stages: + return { + "pipeline_type": pipeline_type, + "stages": stages, + "known": True, + } + except Exception: + pass + return { + "pipeline_type": pipeline_type or "unknown", + "stages": [{"name": s, "gated": False} for s in FALLBACK_STAGES], + "known": False, + } + + +def _resolve_artifact(project_dir: Path, value: Any) -> Optional[dict]: + """Checkpoint artifacts may be inline dicts or path strings — resolve both. + + Path references are only followed INSIDE the project directory: a + checkpoint must not be able to pull arbitrary JSON from elsewhere on + disk onto the board (F-04). + """ + if isinstance(value, dict): + return value + if isinstance(value, str) and value: + p = Path(value) + if not p.is_absolute(): + p = project_dir / value + try: + p.resolve().relative_to(Path(project_dir).resolve()) + except (ValueError, OSError): + return None + return _read_json(p) + return None + + +def _collect_checkpoints(project_dir: Path) -> dict[str, dict]: + """Current checkpoint per stage (raw dicts, unvalidated by design).""" + out: dict[str, dict] = {} + for path in sorted(project_dir.glob("checkpoint_*.json")): + stage = path.stem[len("checkpoint_"):] + data = _read_json(path) + if data is not None: + data["_mtime"] = path.stat().st_mtime + out[stage] = data + return out + + +def _collect_history(project_dir: Path) -> dict[str, list[dict]]: + """Archived checkpoint versions per stage (oldest first).""" + history_dir = project_dir / "history" + out: dict[str, list[dict]] = {} + if not history_dir.is_dir(): + return out + for path in sorted(history_dir.glob("checkpoint_*.json")): + m = re.match(r"checkpoint_(.+?)_\d", path.stem) + stage = m.group(1) if m else path.stem[len("checkpoint_"):] + data = _read_json(path) + if data is not None: + out.setdefault(stage, []).append(data) + return out + + +def _build_stage_rail( + pipeline_meta: dict, + checkpoints: dict[str, dict], + history: dict[str, list[dict]], +) -> list[dict]: + """One entry per manifest stage with derived status + gate audit.""" + rail = [] + manifest_stage_names = {s["name"] for s in pipeline_meta["stages"]} + for stage_def in pipeline_meta["stages"]: + name = stage_def["name"] + cp = checkpoints.get(name) + versions = history.get(name, []) + status = cp.get("status") if cp else "pending" + entry: dict[str, Any] = { + "name": name, + "gated": stage_def["gated"], + "status": status or "pending", + "timestamp": cp.get("timestamp") if cp else None, + "review": cp.get("review") if cp else None, + "cost_snapshot": cp.get("cost_snapshot") if cp else None, + "error": cp.get("error") if cp else None, + "human_approved": cp.get("human_approved") if cp else None, + "partial_progress": (cp.get("metadata") or {}).get("partial_progress") if cp else None, + "versions": len(versions) + (1 if cp else 0), + # Chronological status trail (history + current) — powers replay. + "history_entries": ( + [{"status": v.get("status"), "timestamp": v.get("timestamp")} for v in versions] + + ([{"status": cp.get("status"), "timestamp": cp.get("timestamp")}] if cp else []) + ), + } + # Gate audit: a gated stage that completed without ever passing + # through awaiting_human (current or archived) was gate-skipped. + if ( + stage_def["gated"] + and cp is not None + and cp.get("status") == "completed" + ): + saw_wait = any(v.get("status") == "awaiting_human" for v in versions) + approved = bool(cp.get("human_approved")) + entry["gate_skipped"] = not (saw_wait or approved) + rail.append(entry) + + # Checkpoints for stages the manifest doesn't declare (legacy runs, + # pipeline mismatch) still deserve a slot — at their canonical position + # in the pipeline, not dangling after publish ("idea" belongs up front). + canon = {name: i for i, name in enumerate(FALLBACK_STAGES)} + for name, cp in checkpoints.items(): + if name in manifest_stage_names: + continue + entry = { + "name": name, + "gated": False, + "status": cp.get("status") or "unknown", + "timestamp": cp.get("timestamp"), + "review": cp.get("review"), + "cost_snapshot": cp.get("cost_snapshot"), + "error": cp.get("error"), + "human_approved": cp.get("human_approved"), + "partial_progress": None, + "versions": 1 + len(history.get(name, [])), + "undeclared": True, + } + pos = canon.get(name) + if pos is None: + rail.append(entry) # truly unknown name — end of rail + continue + insert_at = len(rail) + for i, existing in enumerate(rail): + existing_pos = canon.get(existing["name"]) + if existing_pos is not None and existing_pos > pos: + insert_at = i + break + rail.insert(insert_at, entry) + return rail + + +# --------------------------------------------------------------------------- +# Artifacts +# --------------------------------------------------------------------------- + +ARTIFACT_FILES = { + "research_brief": "research_brief.json", + "brief": "brief.json", + "proposal_packet": "proposal_packet.json", + "script": "script.json", + "scene_plan": "scene_plan.json", + "asset_manifest": "asset_manifest.json", + "edit_decisions": "edit_decisions.json", + "render_report": "render_report.json", + "final_review": "final_review.json", + "publish_log": "publish_log.json", + "decision_log": "decision_log.json", +} + + +def _collect_artifacts(project_dir: Path, checkpoints: dict[str, dict]) -> dict[str, dict]: + """Artifacts from artifacts/*.json, backfilled from checkpoint payloads.""" + artifacts: dict[str, dict] = {} + art_dir = project_dir / "artifacts" + for name, filename in ARTIFACT_FILES.items(): + data = _read_json(art_dir / filename) + if data is not None: + artifacts[name] = data + # decision_log historically also lives at project root + if "decision_log" not in artifacts: + data = _read_json(project_dir / "decision_log.json") + if data is not None: + artifacts["decision_log"] = data + # Backfill from checkpoint-embedded artifacts. + for cp in checkpoints.values(): + for name, value in (cp.get("artifacts") or {}).items(): + if name not in artifacts: + resolved = _resolve_artifact(project_dir, value) + if resolved is not None: + artifacts[name] = resolved + return artifacts + + +# --------------------------------------------------------------------------- +# Storyboard join +# --------------------------------------------------------------------------- + +def _resolve_asset_path(project_dir: Path, raw_path: str) -> Optional[Path]: + """Manifest paths appear in several real-world flavors — try them all. + + Observed on disk: project-relative ("assets/images/x.png"), + repo-relative ("projects/<id>/assets/images/x.png"), and absolute. + """ + if not raw_path: + return None + p = Path(raw_path) + candidates = [] + if p.is_absolute(): + candidates.append(p) + else: + candidates.append(project_dir / raw_path) + candidates.append(REPO_ROOT / raw_path) + # repo-relative with the project prefix repeated + parts = p.parts + if len(parts) > 2 and parts[0] == "projects": + candidates.append(project_dir.parent / Path(*parts[1:])) + for c in candidates: + try: + if c.is_file(): + return c + except OSError: + continue + return None + + +def _asset_entry(project_dir: Path, asset: dict) -> dict: + """Normalize a manifest asset entry + resolve file existence. + + A file that resolves OUTSIDE the project directory is treated as + not-servable (exists=False): /media only serves within the project, and + a bare-filename fallback path would 404 or hit the wrong file. + """ + raw_path = asset.get("path") or "" + resolved = _resolve_asset_path(project_dir, raw_path) + if resolved is not None: + try: + resolved.resolve().relative_to(Path(project_dir).resolve()) + except (ValueError, OSError): + resolved = None + file_path = resolved if resolved is not None else (project_dir / raw_path) + exists = resolved is not None + kind = asset.get("type") or "" + if not kind and file_path.suffix: + ext = file_path.suffix.lower() + if ext in MEDIA_IMAGE_EXT: + kind = "image" + elif ext in MEDIA_VIDEO_EXT: + kind = "video" + elif ext in MEDIA_AUDIO_EXT: + kind = "audio" + # A visual is only *renderable* on the board if the file it points at is + # actually a raster image or a video. Bespoke/atelier assets (type + # "animation" pointing at a .tsx composition) exist on disk but can't be + # thumbnailed — routing them to <img> yields a broken image. The board + # falls back to a per-scene snapshot or the shot-spec placeholder instead. + ext = file_path.suffix.lower() + renderable = exists and ext in (MEDIA_IMAGE_EXT | MEDIA_VIDEO_EXT) + return { + "id": asset.get("id"), + "type": kind, + "scene_id": asset.get("scene_id"), + "path": _rel(project_dir, file_path) if exists else raw_path, + "exists": exists, + "renderable": renderable, + "prompt": asset.get("prompt"), + "model": asset.get("model"), + "source_tool": asset.get("source_tool"), + "provider": asset.get("provider"), + "cost_usd": asset.get("cost_usd"), + "quality_score": asset.get("quality_score"), + "duration_seconds": asset.get("duration_seconds"), + "resolution": asset.get("resolution"), + } + + +def _find_scene_snapshot(project_dir: Path, scene_id: str) -> Optional[dict]: + """A per-scene review still, if the run wrote one. + + Atelier/animation scenes have no thumbnailable asset file, so the + assets-stage snapshot (`snapshots/<scene_id>.png`) is what the filmstrip + shows. Accept exact `<scene_id>.<ext>` and `<scene_id>_*.<ext>` forms. + """ + snap_dir = project_dir / "snapshots" + if not scene_id or not snap_dir.is_dir(): + return None + try: + for f in sorted(snap_dir.iterdir()): + if not f.is_file() or f.suffix.lower() not in MEDIA_IMAGE_EXT: + continue + stem = f.stem + if stem == scene_id or stem.startswith(f"{scene_id}_"): + return { + "id": f"snap_{scene_id}", + "type": "image", + "scene_id": scene_id, + "path": _rel(project_dir, f), + "exists": True, + "renderable": True, + "snapshot": True, + } + except OSError: + return None + return None + + +def _find_script_section(scene: dict, sections: list[dict]) -> Optional[dict]: + """Join scene → script section by id, falling back to timing overlap.""" + sid = scene.get("script_section_id") + if sid: + for s in sections: + if s.get("id") == sid: + return s + start = scene.get("start_seconds") + end = scene.get("end_seconds") + if start is None or end is None: + return None + best, best_overlap = None, 0.0 + for s in sections: + s0, s1 = s.get("start_seconds"), s.get("end_seconds") + if s0 is None or s1 is None: + continue + overlap = min(end, s1) - max(start, s0) + if overlap > best_overlap: + best, best_overlap = s, overlap + return best + + +def _build_storyboard( + project_dir: Path, + artifacts: dict[str, dict], + events: list[dict], +) -> Optional[dict]: + """Scene cards: scene_plan × script × asset_manifest (+ live events).""" + scene_plan = artifacts.get("scene_plan") + if not scene_plan or not isinstance(scene_plan.get("scenes"), list): + return None + sections = (artifacts.get("script") or {}).get("sections") or [] + manifest_assets = (artifacts.get("asset_manifest") or {}).get("assets") or [] + + def scene_key(value: Any) -> str: + # 0 is a legitimate scene id — only None/absent collapses to "". + return str(value) if value is not None else "" + + assets_by_scene: dict[str, list[dict]] = {} + for asset in manifest_assets: + if not isinstance(asset, dict): + continue + entry = _asset_entry(project_dir, asset) + assets_by_scene.setdefault(scene_key(entry.get("scene_id")), []).append(entry) + + # A scene is "generating" if its most recent top-level event is an + # unfinished start. Nested (depth>0) provider events inside a selector + # call are skipped — the outer call's finish is the real completion. + generating: dict[str, dict] = {} + for ev in events: + sid = ev.get("scene_id") + if sid is None or ev.get("depth"): + continue + sid = scene_key(sid) + if ev.get("event") == "start": + generating[sid] = ev + elif ev.get("event") in ("finish", "error"): + generating.pop(sid, None) + + cards = [] + for scene in scene_plan["scenes"]: + if not isinstance(scene, dict): + continue + sid = scene_key(scene.get("id")) + section = _find_script_section(scene, sections) + scene_assets = assets_by_scene.get(sid, []) + visuals = [a for a in scene_assets if a["type"] in ("image", "video", "diagram", "animation")] + audio = [a for a in scene_assets if a["type"] in ("audio", "narration", "music", "sfx")] + # Only files that can actually be shown (raster/video) are takes; a + # bespoke composition asset (.tsx animation) is real but not showable. + renderable = [a for a in visuals if a.get("renderable")] + # A raster/video asset whose FILE is missing stays as a "file missing" + # indicator. But an asset that EXISTS yet can't be shown (a .tsx atelier + # composition) is dropped — it falls back to a per-scene snapshot. + missing = [a for a in visuals if not a.get("exists") and a["type"] in ("image", "video", "diagram")] + active_visual = ( + renderable[-1] if renderable + else missing[-1] if missing + else _find_scene_snapshot(project_dir, sid) + ) + cards.append({ + "id": sid, + "type": scene.get("type"), + "description": scene.get("description"), + "start_seconds": scene.get("start_seconds"), + "end_seconds": scene.get("end_seconds"), + "duration_seconds": ( + max(0, (scene.get("end_seconds") or 0) - (scene.get("start_seconds") or 0)) + if scene.get("end_seconds") is not None and scene.get("start_seconds") is not None + else None + ), + "hero_moment": bool(scene.get("hero_moment")), + "shot_language": scene.get("shot_language"), + "shot_intent": scene.get("shot_intent"), + "framing": scene.get("framing"), + "movement": scene.get("movement"), + "narration": (section or {}).get("text"), + "section_label": (section or {}).get("label"), + "required_assets": scene.get("required_assets") or [], + "visual": active_visual, + "takes": renderable, + "audio": audio, + "generating": generating.get(sid) is not None, + "generating_tool": (generating.get(sid) or {}).get("tool"), + }) + + total = scene_plan.get("metadata", {}).get("total_duration_seconds") + if total is None and cards: + ends = [c["end_seconds"] for c in cards if c["end_seconds"] is not None] + total = max(ends) if ends else None + return { + "scenes": cards, + "total_duration_seconds": total, + "style_playbook": scene_plan.get("style_playbook"), + } + + +# --------------------------------------------------------------------------- +# Media discovery +# --------------------------------------------------------------------------- + +def _scan_media(project_dir: Path) -> dict[str, list[dict]]: + """Discovered media files (renders, loose assets, snapshots).""" + renders: list[dict] = [] + snapshots: list[dict] = [] + music: list[dict] = [] + + renders_dir = project_dir / "renders" + if renders_dir.is_dir(): + for f in sorted(renders_dir.iterdir()): + if f.suffix.lower() in MEDIA_VIDEO_EXT and f.is_file(): + renders.append({"path": _rel(project_dir, f), "size": f.stat().st_size, + "mtime": f.stat().st_mtime}) + # Atelier heuristic: deliverables at project root. + for f in sorted(project_dir.glob("*.mp4")): + renders.append({"path": _rel(project_dir, f), "size": f.stat().st_size, + "mtime": f.stat().st_mtime, "at_root": True}) + for f in sorted(project_dir.glob("*.mp3")): + music.append({"path": _rel(project_dir, f), "at_root": True}) + music_dir = project_dir / "assets" / "music" + if music_dir.is_dir(): + for f in sorted(music_dir.iterdir()): + if f.suffix.lower() in MEDIA_AUDIO_EXT: + music.append({"path": _rel(project_dir, f)}) + + for dirname in ("snapshots", "verify"): + d = project_dir / dirname + if d.is_dir(): + for f in sorted(d.iterdir()): + if f.suffix.lower() in MEDIA_IMAGE_EXT and f.is_file(): + snapshots.append({"path": _rel(project_dir, f)}) + + renders.sort(key=lambda r: r.get("mtime", 0), reverse=True) + return {"renders": renders, "snapshots": snapshots, "music": music} + + +def _find_poster(project_dir: Path, state: dict) -> Optional[str]: + """Best poster for the library card (image path, or a video path — + the /thumb endpoint extracts a frame from videos).""" + board = state.get("storyboard") or {} + for card in board.get("scenes", []): + visual = card.get("visual") + if visual and visual.get("exists") and visual.get("type") == "image": + return visual["path"] + for snap in (state.get("media") or {}).get("snapshots", []): + return snap["path"] + # Common image homes, in order of how representative they usually are. + for rel_dir in ("assets/images", "assets/frames", "exports", "assets", "."): + d = (project_dir / rel_dir) if rel_dir != "." else project_dir + if not d.is_dir(): + continue + try: + for f in sorted(d.iterdir()): + if f.is_file() and f.suffix.lower() in MEDIA_IMAGE_EXT: + return _rel(project_dir, f) + except OSError: + continue + # Last resort: the newest render — /thumb extracts a poster frame. + renders = (state.get("media") or {}).get("renders", []) + if renders: + return renders[0]["path"] + return None + + +def _last_activity(project_dir: Path) -> float: + """Most recent mtime among state-bearing files (bounded scan).""" + latest = 0.0 + try: + candidates = list(project_dir.glob("checkpoint_*.json")) + candidates.append(project_dir / "events.jsonl") + art = project_dir / "artifacts" + if art.is_dir(): + candidates.extend(art.glob("*.json")) + for p in candidates: + try: + latest = max(latest, p.stat().st_mtime) + except OSError: + continue + except OSError: + pass + return latest + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def load_board_state(project_dir: Path) -> dict[str, Any]: + """Full BoardState for one project. Never raises.""" + project_dir = Path(project_dir) + project_id = project_dir.name + + marker = _read_json(project_dir / "project.json") or {} + meta_json = _read_json(project_dir / "meta.json") or {} + + checkpoints = _collect_checkpoints(project_dir) + history = _collect_history(project_dir) + + pipeline_type = marker.get("pipeline_type") + if not pipeline_type: + for cp in checkpoints.values(): + pt = cp.get("pipeline_type") + if pt and pt != "unknown": + pipeline_type = pt + break + pipeline_meta = _load_pipeline_meta(pipeline_type) + + artifacts = _collect_artifacts(project_dir, checkpoints) + events = read_events(project_dir, limit=250) + storyboard = _build_storyboard(project_dir, artifacts, events) + media = _scan_media(project_dir) + + stages = _build_stage_rail(pipeline_meta, checkpoints, history) + + # Cost: latest checkpoint snapshot wins; fall back to manifest total. + cost = None + for cp in sorted(checkpoints.values(), key=lambda c: c.get("_mtime", 0), reverse=True): + if cp.get("cost_snapshot"): + cost = cp["cost_snapshot"] + break + if cost is None: + total = (artifacts.get("asset_manifest") or {}).get("total_cost_usd") + if total is not None: + cost = {"total_spent_usd": total} + + import time + last_activity = _last_activity(project_dir) + now = time.time() + + # Stall detection: an in_progress stage that stopped writing anything. + for stage_entry in stages: + if ( + stage_entry["status"] == "in_progress" + and last_activity + and (now - last_activity) > STALL_WINDOW_SECONDS + ): + stage_entry["stalled"] = True + stage_entry["stalled_minutes"] = int((now - last_activity) / 60) + + state: dict[str, Any] = { + "project_id": project_id, + "title": marker.get("title") or meta_json.get("name") or project_id.replace("-", " ").title(), + "pipeline": pipeline_meta, + "style_playbook": marker.get("style_playbook"), + "created_at": marker.get("created_at"), + "has_marker": bool(marker), + "has_pipeline_state": bool(checkpoints), + "stages": stages, + "artifacts": artifacts, + "storyboard": storyboard, + "media": media, + "events": events, + "cost": cost, + "last_activity": last_activity, + "live": bool(last_activity and (now - last_activity) < LIVE_WINDOW_SECONDS), + } + state["poster"] = _find_poster(project_dir, state) + return state + + +def summarize_project(project_dir: Path) -> dict[str, Any]: + """Cheap library-card summary (no full artifact parse of big files).""" + state = load_board_state(project_dir) + active = next((s for s in state["stages"] if s["status"] in ("in_progress", "awaiting_human")), None) + done = [s for s in state["stages"] if s["status"] == "completed"] + return { + "project_id": state["project_id"], + "title": state["title"], + "pipeline_type": state["pipeline"]["pipeline_type"], + "has_pipeline_state": state["has_pipeline_state"], + "poster": state["poster"], + "live": state["live"], + "last_activity": state["last_activity"], + "active_stage": active["name"] if active else None, + "awaiting_human": bool(active and active["status"] == "awaiting_human"), + "stage_states": [ + {"name": s["name"], "status": s["status"]} + for s in state["stages"] if not s.get("undeclared") + ], + "completed_count": len(done), + "render_count": len(state["media"]["renders"]), + "scene_count": len((state["storyboard"] or {}).get("scenes", [])), + } + + +def list_projects(projects_dir: Optional[Path] = None) -> list[dict[str, Any]]: + """Library view: every project directory, live-first then recency.""" + root = Path(projects_dir) if projects_dir else PROJECTS_DIR + if not root.is_dir(): + return [] + summaries = [] + for entry in sorted(root.iterdir()): + if not entry.is_dir() or entry.name.startswith(("_", ".")): + continue + try: + summaries.append(summarize_project(entry)) + except Exception: + summaries.append({ + "project_id": entry.name, + "title": entry.name.replace("-", " ").title(), + "pipeline_type": "unknown", + "has_pipeline_state": False, + "poster": None, + "live": False, + "last_activity": 0, + "active_stage": None, + "awaiting_human": False, + "stage_states": [], + "completed_count": 0, + "render_count": 0, + "scene_count": 0, + "error": "unreadable", + }) + summaries.sort(key=lambda s: (not s["live"], -(s["last_activity"] or 0))) + return summaries diff --git a/backlot/ui/board.css b/backlot/ui/board.css new file mode 100644 index 00000000..e4de79ff --- /dev/null +++ b/backlot/ui/board.css @@ -0,0 +1,635 @@ +/* ============================================================ + BACKLOT — Living Storyboard design system (mockup) + Dark-room editorial: near-black matte canvas, artifacts glow. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap'); + +:root { + --bg: #0a0a0c; + --surface: #101013; + --surface-2: #16161a; + --surface-3: #1c1c21; + --border: #232329; + --border-soft: #1a1a1f; + --text: #ececef; + --text-2: #a0a0a9; + --text-3: #5f5f68; + --amber: #f0a83c; + --amber-dim: rgba(240, 168, 60, 0.14); + --green: #4fc283; + --green-dim: rgba(79, 194, 131, 0.12); + --red: #e5544b; + --red-dim: rgba(229, 84, 75, 0.12); + --blue: #6aa1ff; + --cream: #f2e9d5; + --cream-shade: #e5d9be; + --cream-ink: #29231a; + --cream-ink-2: #6b5f4a; + --sans: 'Inter', -apple-system, sans-serif; + --mono: 'JetBrains Mono', ui-monospace, monospace; + --screenplay: 'Courier Prime', 'Courier New', monospace; + + /* Global type scale. Every font-size is calc(<px> * var(--fs-scale)), so this + one number scales all text proportionally for readability. 1 = original. */ + --fs-scale: 1.16; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { color-scheme: dark; } +::-webkit-scrollbar { width: 10px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #26262e; border-radius: 6px; border: 2px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: #34343e; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: calc(14px * var(--fs-scale)); + line-height: 1.5; + min-height: 100vh; + /* faint vignette so media pops */ + background-image: radial-gradient(1200px 600px at 50% -100px, #111116 0%, var(--bg) 70%); +} + +.wrap { max-width: 1440px; margin: 0 auto; padding: 0 28px 80px; } + +/* film grain — barely-there, keeps the dark room from feeling flat */ +body::after { + content: ''; position: fixed; inset: -50%; pointer-events: none; z-index: 90; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); + opacity: .035; animation: grain 1.2s steps(4) infinite; +} +@keyframes grain { + 0%, 100% { transform: translate(0,0); } + 25% { transform: translate(-1.5%, 1%); } + 50% { transform: translate(1%, -1.5%); } + 75% { transform: translate(-1%, -1%); } +} + +/* everything enters like a story unfolding */ +@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } +.slate { animation: rise .5s cubic-bezier(.2,.7,.3,1) backwards; } +.rail .stage { animation: rise .5s cubic-bezier(.2,.7,.3,1) backwards; } +.rail .stage:nth-child(1) { animation-delay: .06s } .rail .stage:nth-child(2) { animation-delay: .11s } +.rail .stage:nth-child(3) { animation-delay: .16s } .rail .stage:nth-child(4) { animation-delay: .21s } +.rail .stage:nth-child(5) { animation-delay: .26s } .rail .stage:nth-child(6) { animation-delay: .31s } +.rail .stage:nth-child(7) { animation-delay: .36s } .rail .stage:nth-child(8) { animation-delay: .41s } +.script-card, .notice { animation: rise .6s cubic-bezier(.2,.7,.3,1) .25s backwards; } +aside .panel { animation: rise .6s cubic-bezier(.2,.7,.3,1) backwards; } +aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2) { animation-delay: .42s } +.scene-card { animation: rise .65s cubic-bezier(.2,.7,.3,1) backwards; } +.scene-card:nth-child(1) { animation-delay: .35s } .scene-card:nth-child(2) { animation-delay: .43s } +.scene-card:nth-child(3) { animation-delay: .51s } .scene-card:nth-child(4) { animation-delay: .59s } +.scene-card:nth-child(5) { animation-delay: .67s } .scene-card:nth-child(6) { animation-delay: .75s } +.scene-card:nth-child(7) { animation-delay: .83s } .scene-card:nth-child(8) { animation-delay: .91s } +.scene-card:nth-child(9) { animation-delay: .99s } .scene-card:nth-child(10) { animation-delay: 1.07s } +.scene-card:nth-child(11) { animation-delay: 1.15s } .scene-card:nth-child(12) { animation-delay: 1.23s } +.lib-card { animation: rise .6s cubic-bezier(.2,.7,.3,1) backwards; } +.lib-card:nth-child(1) { animation-delay: .08s } .lib-card:nth-child(2) { animation-delay: .15s } +.lib-card:nth-child(3) { animation-delay: .22s } .lib-card:nth-child(4) { animation-delay: .29s } +.lib-card:nth-child(5) { animation-delay: .36s } .lib-card:nth-child(6) { animation-delay: .43s } +.lib-card:nth-child(7) { animation-delay: .50s } .lib-card:nth-child(8) { animation-delay: .57s } + +/* ---------- header slate ---------- */ +.slate { + display: flex; align-items: center; gap: 18px; + padding: 18px 0 16px; + border-bottom: 1px solid var(--border-soft); +} +.clapper { + width: 34px; height: 26px; border-radius: 4px; flex: none; + background: repeating-linear-gradient(-45deg, #2c2c33 0 6px, #101013 6px 12px); + border: 1px solid var(--border); +} +.slate h1 { + font-family: var(--mono); font-size: calc(17px * var(--fs-scale)); font-weight: 600; + letter-spacing: 0.08em; text-transform: uppercase; +} +.slate .wordmark { + font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); letter-spacing: 0.22em; + color: var(--text-3); text-transform: uppercase; margin-right: 2px; +} +.chip { + font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); letter-spacing: 0.06em; + padding: 3px 9px; border-radius: 99px; + border: 1px solid var(--border); color: var(--text-2); + white-space: nowrap; +} +.chip.warn { border-color: rgba(240,168,60,.4); color: var(--amber); background: var(--amber-dim); } +.slate .spacer { flex: 1; } + +.live { + display: inline-flex; align-items: center; gap: 7px; + font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); letter-spacing: 0.14em; + color: var(--amber); +} +.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s ease-in-out infinite; } +.live.idle { color: var(--text-3); } +.live.idle .dot { background: var(--text-3); animation: none; } +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(240,168,60,.5); opacity: 1; } + 50% { box-shadow: 0 0 0 7px rgba(240,168,60,0); opacity: .75; } +} + +.cost { text-align: right; } +.cost .nums { font-family: var(--mono); font-size: calc(13px * var(--fs-scale)); } +.cost .nums b { color: var(--text); font-weight: 600; } +.cost .nums span { color: var(--text-3); } +.cost .bar { width: 150px; height: 3px; background: var(--surface-3); border-radius: 3px; margin-top: 5px; overflow: hidden; } +.cost .bar i { display: block; height: 100%; background: var(--green); border-radius: 3px; } +.cost .bar i.warn { background: var(--amber); } +.cost .label { font-size: calc(10px * var(--fs-scale)); color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; } + +/* ---------- stage rail ---------- */ +.rail { display: flex; align-items: flex-start; padding: 26px 0 22px; } +.stage { flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; min-width: 0; } +.stage .node { + width: 26px; height: 26px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: calc(12px * var(--fs-scale)); z-index: 2; position: relative; + background: var(--surface-2); border: 1.5px solid var(--border); + color: var(--text-3); +} +.stage .line { + position: absolute; top: 13px; left: calc(-50% + 13px); right: calc(50% + 13px); + height: 1.5px; background: var(--border); +} +.stage:first-child .line { display: none; } +.stage .name { + margin-top: 10px; font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); + letter-spacing: 0.05em; color: var(--text-3); +} +.stage .sub { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-top: 3px; text-align: center; max-width: 150px; } + +.stage.done .node { background: var(--surface-3); border-color: #3a3a42; color: var(--green); } +.stage.done .line { background: #3a3a42; } +.stage.done .name { color: var(--text-2); } + +.stage.active .node { + border-color: var(--amber); color: var(--amber); background: var(--amber-dim); + animation: ringpulse 1.8s ease-in-out infinite; +} +.stage.active .line { background: linear-gradient(90deg, #3a3a42, rgba(240,168,60,.55)); overflow: hidden; } +.stage.active .line::after { /* energy traveling toward the live stage */ + content: ''; position: absolute; top: 0; bottom: 0; width: 34px; left: -40px; + background: linear-gradient(90deg, transparent, rgba(240,168,60,.95), transparent); + animation: travel 1.7s ease-in-out infinite; +} +@keyframes travel { to { left: calc(100% + 6px); } } +.stage.active .name { color: var(--amber); font-weight: 600; } +.stage.active .sub { color: var(--text-2); } +@keyframes ringpulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(240,168,60,.45); } + 50% { box-shadow: 0 0 0 9px rgba(240,168,60,0); } +} + +.stage.await .node { border-color: var(--amber); color: var(--amber); background: var(--amber-dim); box-shadow: 0 0 18px rgba(240,168,60,.25); } +.stage.await .line { background: linear-gradient(90deg, #3a3a42, var(--amber)); } +.stage.await .name { color: var(--amber); font-weight: 600; } +.stage.await .sub { color: var(--amber); } + +.stage.failed .node { border-color: var(--red); color: var(--red); background: var(--red-dim); } +.stage.failed .name { color: var(--red); } + +/* ---------- layout ---------- */ +.board { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; } +.main-col { min-width: 0; } + +.panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; } +.panel + .panel { margin-top: 18px; } +.panel-head { + display: flex; align-items: baseline; gap: 10px; + padding: 13px 16px 11px; border-bottom: 1px solid var(--border-soft); +} +.panel-head h2 { font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); font-weight: 600; letter-spacing: 0.18em; color: var(--text-2); text-transform: uppercase; } +.panel-head .meta { font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-left: auto; } +.panel-body { padding: 14px 16px; } + +/* ---------- screenplay card ---------- */ +.script-card { + background: linear-gradient(178deg, var(--cream) 0%, var(--cream-shade) 130%); + color: var(--cream-ink); + border-radius: 6px; + padding: 34px 44px 26px; + max-width: 700px; /* screenplay pages are narrow — paper on a dark desk */ + margin: 0 auto; + font-family: var(--screenplay); + box-shadow: 0 18px 50px -18px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.06) inset; + position: relative; + cursor: pointer; +} +.script-card::after { /* page edge */ + content: ''; position: absolute; right: 7px; top: 7px; bottom: 7px; width: 1px; + background: rgba(0,0,0,.07); +} +.script-card .sp-title { + text-align: center; font-weight: 700; font-size: calc(16px * var(--fs-scale)); + letter-spacing: 0.12em; text-transform: uppercase; + margin-bottom: 4px; +} +.script-card .sp-meta { text-align: center; font-size: calc(11.5px * var(--fs-scale)); color: var(--cream-ink-2); margin-bottom: 26px; } +.script-card .sp-slug { + font-weight: 700; font-size: calc(12.5px * var(--fs-scale)); text-transform: uppercase; + letter-spacing: 0.04em; margin: 18px 0 6px; +} +.script-card .sp-slug .tc { color: var(--cream-ink-2); font-weight: 400; float: right; font-size: calc(11px * var(--fs-scale)); } +.script-card .sp-action { font-size: calc(13px * var(--fs-scale)); line-height: 1.62; } +.script-card .sp-paren { font-size: calc(11.5px * var(--fs-scale)); font-style: italic; color: var(--cream-ink-2); margin: 4px 0 0 42px; } +.script-card .sp-cue { + display: inline-block; font-family: var(--mono); font-size: calc(9.5px * var(--fs-scale)); font-style: normal; + background: rgba(0,0,0,.06); border-radius: 3px; padding: 1px 6px; margin: 6px 0 0; + color: #7d6f52; letter-spacing: .03em; +} +.script-card .sp-fade { text-align: right; font-size: calc(12px * var(--fs-scale)); font-weight: 700; margin-top: 20px; text-transform: uppercase; } +.script-card .sp-expand { + position: absolute; right: 16px; bottom: 12px; + font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--cream-ink-2); letter-spacing: .06em; +} +.script-approved { + position: absolute; top: 20px; right: 26px; + font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); font-weight: 600; letter-spacing: .14em; + color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px; + padding: 3px 8px; transform: rotate(6deg); opacity: .8; +} + +/* ---------- right rail: decisions & activity ---------- */ +.decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); } +.decision:last-child { border-bottom: none; } +.decision .d-head { display: flex; gap: 8px; align-items: baseline; } +.decision .d-cat { font-family: var(--mono); font-size: calc(9.5px * var(--fs-scale)); color: var(--text-3); letter-spacing: .1em; text-transform: uppercase; } +.decision .d-revised { color: var(--amber); } +.decision .d-pick { font-size: calc(12.5px * var(--fs-scale)); font-weight: 600; margin-top: 3px; } +.decision .d-pick .arrow { color: var(--amber); font-weight: 400; } +.decision .d-why { font-size: calc(11.5px * var(--fs-scale)); color: var(--text-2); margin-top: 3px; line-height: 1.45; } +.decision .d-alt { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-top: 4px; } +.decision .d-alt s { opacity: .8; } + +.act-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); } +.act-row:last-child { border-bottom: none; } +.act-row .t { color: var(--text-3); font-size: calc(10px * var(--fs-scale)); flex: none; } +.act-row .tool { color: var(--text-2); } +.act-row .target { color: var(--text-3); } +.act-row .status { margin-left: auto; flex: none; font-size: calc(10.5px * var(--fs-scale)); } +.act-row .status.ok { color: var(--green); } +.act-row .status.run { color: var(--amber); animation: blink 1.4s ease-in-out infinite; } +.act-row .status.err { color: var(--red); } +@keyframes blink { 50% { opacity: .45; } } + +/* ---------- filmstrip ---------- */ +.strip-outer { position: relative; } +.filmstrip { + display: flex; gap: 12px; overflow-x: auto; padding: 26px 4px; + /* sprocket holes */ + background: + radial-gradient(circle 3.5px, #2e2e36 97%, transparent) 0 6px / 26px 10px repeat-x, + radial-gradient(circle 3.5px, #2e2e36 97%, transparent) 0 calc(100% - 16px) / 26px 10px repeat-x; +} +.filmstrip { scrollbar-width: thin; scrollbar-color: #26262e transparent; } +.scene-card { flex: none; display: flex; flex-direction: column; position: relative; } +.scene-card .sc-slate { + display: flex; align-items: baseline; gap: 8px; + font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); letter-spacing: .05em; + color: var(--text-3); padding: 0 2px 6px; +} +.scene-card .sc-slate .num { color: var(--text-2); font-weight: 600; } +.scene-card .sc-slate .take { color: var(--amber); } +.scene-card .sc-slate .dur { margin-left: auto; } +.scene-card .sc-slate .hero { color: var(--amber); letter-spacing: .1em; } + +.thumb { + border-radius: 7px; overflow: hidden; position: relative; + aspect-ratio: 16 / 9; background: var(--surface-2); + border: 1px solid var(--border); +} +.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +/* Videos must fill the thumb box exactly — without this the <video> renders at + its intrinsic size, so the visible frame and the clickable box drift apart + (clicking the picture did nothing; clicking below it toggled play). */ +.thumb video { width: 100%; height: 100%; object-fit: cover; display: block; } +.thumb.approved { cursor: pointer; } +/* bespoke/atelier scene placeholder */ +.thumb.spec.bespoke { border-color: rgba(240,168,60,.4); } +.thumb.spec .bespoke-tag { + font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); letter-spacing: .1em; + color: var(--amber); margin-bottom: 2px; +} +.thumb .badge { + position: absolute; left: 7px; bottom: 7px; + font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); letter-spacing: .06em; + background: rgba(8,8,10,.72); color: var(--text-2); + padding: 2px 7px; border-radius: 3px; backdrop-filter: blur(4px); +} +.thumb .play { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + color: rgba(255,255,255,.85); font-size: calc(26px * var(--fs-scale)); text-shadow: 0 2px 12px rgba(0,0,0,.7); + opacity: 0; transition: opacity .18s; +} +.thumb:hover .play { opacity: 1; } +.thumb.approved { border-color: rgba(79,194,131,.35); } + +/* generating shimmer */ +.thumb.generating { border-color: rgba(240,168,60,.45); } +.thumb.generating .shimmer { + position: absolute; inset: 0; + background: linear-gradient(100deg, var(--surface-2) 32%, #24242c 48%, var(--surface-2) 64%); + background-size: 220% 100%; + animation: shimmer 1.5s linear infinite; +} +@keyframes shimmer { to { background-position: -120% 0; } } +.thumb.generating .gen-label { + position: absolute; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 6px; padding: 0 14px; text-align: center; + font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--amber); letter-spacing: .08em; +} +.thumb.generating .gen-label .sub { color: var(--text-3); font-size: calc(9.5px * var(--fs-scale)); letter-spacing: .03em; line-height: 1.5; } + +/* pending spec card */ +.thumb.spec { border-style: dashed; border-color: #2c2c34; background: transparent; } +.thumb.spec .spec-in { + position: absolute; inset: 0; padding: 10px 12px; + display: flex; flex-direction: column; justify-content: center; gap: 4px; +} +.thumb.spec .spec-desc { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.thumb.spec .spec-shot { font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); color: #4a4a54; letter-spacing: .04em; } + +/* missing asset */ +.thumb.missing { border-color: rgba(240,168,60,.55); border-style: dashed; background: var(--amber-dim); } +.thumb.missing .spec-in { align-items: center; text-align: center; } +.thumb.missing .warn-ic { color: var(--amber); font-size: calc(15px * var(--fs-scale)); } +.thumb.missing .spec-desc { color: var(--amber); -webkit-line-clamp: 2; } + +/* text-card scene (typographic placeholder) */ +.thumb.textcard { display: flex; align-items: center; justify-content: center; background: #0d0d10; } +.thumb.textcard .tc-copy { + font-family: var(--mono); font-weight: 500; text-align: center; + letter-spacing: .2em; font-size: calc(11px * var(--fs-scale)); color: #d8d8de; padding: 0 10px; +} + +.narr { + padding: 8px 3px 0; font-size: calc(11px * var(--fs-scale)); color: var(--text-2); line-height: 1.45; + font-style: italic; max-height: 52px; overflow: hidden; position: relative; +} +/* Long narration is clamped with a soft fade + expand glyph; click opens the + full text in the modal instead of hard-cutting mid-word. */ +.narr.clip { + cursor: pointer; + -webkit-mask-image: linear-gradient(180deg, #000 62%, transparent); + mask-image: linear-gradient(180deg, #000 62%, transparent); +} +.narr .narr-more { + position: absolute; right: 2px; bottom: 2px; font-style: normal; + color: var(--text-3); font-size: calc(11px * var(--fs-scale)); +} +.narr.clip:hover { color: var(--text-1); } +.narr.tc-note { color: var(--text-3); } + +.wave { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; padding: 6px 3px 0; } +.wave i { width: 2.5px; background: #3d3d47; border-radius: 1px; } +.wave.played i { background: #565664; } +.wave .wv-time { font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); color: var(--text-3); margin-left: 6px; align-self: center; } + +/* takes drawer */ +.takes { display: flex; gap: 5px; padding: 8px 2px 0; align-items: center; } +.takes .tk { width: 44px; aspect-ratio: 16/9; border-radius: 3px; overflow: hidden; border: 1px solid var(--border); opacity: .55; position: relative; } +.takes .tk img { width: 100%; height: 100%; object-fit: cover; } +.takes .tk.active { opacity: 1; border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber); } +.takes .tk-label { font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); color: var(--text-3); letter-spacing: .05em; } + +/* ---------- empty state ---------- */ +.empty { + border: 1.5px dashed #26262e; border-radius: 10px; padding: 40px; + text-align: center; color: var(--text-3); +} +.empty .big { font-family: var(--mono); font-size: calc(12px * var(--fs-scale)); letter-spacing: .12em; text-transform: uppercase; margin-bottom: 6px; color: #4a4a54; } + +/* ---------- review findings ---------- */ +.findings { display: flex; gap: 8px; align-items: center; font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); } +.findings .f { padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text-3); } +.findings .f.crit { color: var(--red); border-color: rgba(229,84,75,.35); } +.findings .f.sugg { color: var(--amber); border-color: rgba(240,168,60,.3); } + +/* ---------- modal ---------- */ +.modal-bg { + position: fixed; inset: 0; background: rgba(5,5,7,.82); backdrop-filter: blur(6px); + display: none; align-items: flex-start; justify-content: center; overflow-y: auto; + padding: 48px 20px; z-index: 50; +} +.modal-bg.open { display: flex; } +.modal-page { max-width: 640px; width: 100%; } +.modal-close { + position: fixed; top: 18px; right: 26px; font-family: var(--mono); + color: var(--text-2); font-size: calc(12px * var(--fs-scale)); cursor: pointer; letter-spacing: .1em; + background: var(--surface-2); border: 1px solid var(--border); border-radius: 99px; padding: 6px 14px; +} + +/* ---------- library ---------- */ +.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; padding-top: 24px; } +.lib-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; overflow: hidden; transition: border-color .15s, transform .15s; } +.lib-card:hover { border-color: #34343e; transform: translateY(-2px); } +.lib-card.live-card { border-color: rgba(240,168,60,.4); } +.lib-poster { aspect-ratio: 16/9; background: var(--surface-2); position: relative; overflow: hidden; } +.lib-poster img { width: 100%; height: 100%; object-fit: cover; display: block; } +.lib-poster .lp-live { + position: absolute; top: 9px; left: 9px; font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); letter-spacing: .12em; + color: var(--amber); background: rgba(8,8,10,.75); border: 1px solid rgba(240,168,60,.45); + padding: 3px 8px; border-radius: 99px; display: flex; gap: 5px; align-items: center; backdrop-filter: blur(4px); +} +.lib-poster .lp-live .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s infinite; } +.lib-poster .lp-txt { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--mono); letter-spacing: .16em; font-size: calc(12px * var(--fs-scale)); color: #3f3f4a; } +.lib-body { padding: 13px 15px 14px; } +.lib-body h3 { font-family: var(--mono); font-size: calc(12.5px * var(--fs-scale)); font-weight: 600; letter-spacing: .05em; } +.lib-body .lb-meta { display: flex; gap: 8px; margin-top: 5px; align-items: center; } +.lib-body .lb-meta .chip { font-size: calc(9.5px * var(--fs-scale)); padding: 2px 7px; } +.lib-body .lb-meta .when { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-left: auto; } +.mini-rail { display: flex; gap: 4px; margin-top: 11px; align-items: center; } +.mini-rail i { height: 4px; flex: 1; border-radius: 2px; background: var(--surface-3); } +.mini-rail i.d { background: #3d5c4b; } +.mini-rail i.a { background: var(--amber); animation: blink 1.4s infinite; } +.mini-rail i.w { background: var(--amber); } + +/* ---------- misc ---------- */ +.notice { + display: flex; gap: 10px; align-items: center; + border: 1px solid rgba(240,168,60,.3); background: var(--amber-dim); + border-radius: 9px; padding: 11px 15px; font-size: calc(12.5px * var(--fs-scale)); color: var(--text-2); margin: 18px 0 4px; +} +.notice b { color: var(--amber); font-weight: 600; } +.section-title { + font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); font-weight: 600; letter-spacing: .18em; + text-transform: uppercase; color: var(--text-2); padding: 26px 0 2px; + display: flex; align-items: baseline; gap: 12px; +} +.section-title .meta { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); font-weight: 400; letter-spacing: .05em; margin-left: auto; } +a.backlink { font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); text-decoration: none; letter-spacing: .08em; } +a.backlink:hover { color: var(--text-2); } + +/* ============================================================ + Live-board additions (beyond the mockup design system) + ============================================================ */ + +/* stage nodes are interactive on the real board */ +.stage { cursor: pointer; border-radius: 8px; padding: 4px 2px; transition: background .15s; } +.stage:hover { background: rgba(255,255,255,.025); } +.stage.selected .name { text-decoration: underline; text-underline-offset: 4px; } + +/* stage drawer */ +.drawer { + border: 1px solid var(--border-soft); background: var(--surface); + border-radius: 12px; margin: 0 0 20px; overflow: hidden; + animation: rise .35s cubic-bezier(.2,.7,.3,1); +} +.drawer .drawer-head { + display: flex; gap: 10px; align-items: baseline; + padding: 12px 16px; border-bottom: 1px solid var(--border-soft); +} +.drawer .drawer-head h3 { font-family: var(--mono); font-size: calc(12px * var(--fs-scale)); letter-spacing: .14em; text-transform: uppercase; } +.drawer .drawer-head .close { margin-left: auto; cursor: pointer; color: var(--text-3); font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); } +.drawer .drawer-head .close:hover { color: var(--text-2); } +.drawer .drawer-body { padding: 14px 16px; } +.drawer pre { + font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); line-height: 1.55; color: var(--text-2); + background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: 8px; + padding: 12px 14px; overflow: auto; max-height: 420px; white-space: pre-wrap; +} +.gate-chip { + font-family: var(--mono); font-size: calc(9.5px * var(--fs-scale)); letter-spacing: .08em; + padding: 2px 8px; border-radius: 99px; border: 1px solid rgba(229,84,75,.45); + color: var(--red); background: var(--red-dim); +} +.ver-chip { + font-family: var(--mono); font-size: calc(9.5px * var(--fs-scale)); letter-spacing: .06em; + padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text-3); +} + +/* render section */ +.render-hero { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: #000; } +.render-hero video { width: 100%; display: block; max-height: 560px; } +.render-meta { font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); padding: 8px 2px; display: flex; gap: 14px; flex-wrap: wrap; } +.render-meta .v { color: var(--text-2); cursor: pointer; } +.render-meta .v.active { color: var(--amber); } + +/* audio playback affordance */ +.narr-audio { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text-3); } +.narr-audio:hover { color: var(--amber); } + +/* found-media grids (degraded view) */ +.found-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } +.found-grid .thumb { aspect-ratio: 16/9; } + +/* replay bar (phase 3) */ +.replay-bar { + display: flex; align-items: center; gap: 14px; + border: 1px solid var(--border-soft); background: var(--surface); + border-radius: 10px; padding: 10px 16px; margin: 14px 0; +} +.replay-bar input[type=range] { flex: 1; accent-color: var(--amber); } +.replay-bar .rp-btn { + font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); letter-spacing: .08em; cursor: pointer; + border: 1px solid var(--border); border-radius: 99px; padding: 4px 12px; color: var(--text-2); + background: var(--surface-2); +} +.replay-bar .rp-btn:hover { color: var(--amber); border-color: rgba(240,168,60,.4); } +.replay-bar .rp-time { font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); min-width: 130px; text-align: right; } +body.replaying .live .dot { background: var(--blue); animation: none; } + +/* filmstrip thumbs at fixed height (duration drives width) */ +.filmstrip .thumb { height: 118px; aspect-ratio: auto; } + +/* empty board hints */ +.hint { font-size: calc(12px * var(--fs-scale)); color: var(--text-3); padding: 10px 2px; } + +a { color: inherit; } + +/* entrance choreography plays only on first paint, not on every SSE refresh */ +body:not(.first) .slate, body:not(.first) .rail .stage, +body:not(.first) .script-card, body:not(.first) .notice, +body:not(.first) aside .panel, body:not(.first) .scene-card, +body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; } + +/* stages that ran but aren't declared by the pipeline manifest */ +.stage.undeclared .node { border-style: dashed; opacity: .85; } +.stage.undeclared .name { font-style: italic; } + +/* spend past 90% of budget */ +.cost .bar i.crit { background: var(--red); } + +/* in_progress stage with no filesystem activity for a while (F-05) */ +.stage.stalled .node { border-color: var(--red); color: var(--red); background: var(--red-dim); animation: none; } +.stage.stalled .name { color: var(--red); } +.stage.stalled .sub { color: var(--red); } + +/* responsive project board */ +@media (max-width: 900px) { + .wrap { max-width: none; width: 100%; padding: 0 18px 64px; overflow-x: clip; } + .slate { flex-wrap: wrap; align-items: flex-start; gap: 10px 12px; } + .slate > div:nth-child(2) { min-width: 0; flex: 1 1 240px; } + .slate h1 { overflow-wrap: anywhere; } + .slate .spacer { display: none; } + .cost { text-align: left; } + .cost .bar { width: min(150px, 38vw); } + + .rail { + overflow-x: auto; + overscroll-behavior-x: contain; + padding: 18px 0 16px; + scrollbar-width: thin; + } + .stage { flex: 0 0 82px; } + .stage .name { font-size: calc(10px * var(--fs-scale)); max-width: 76px; overflow-wrap: anywhere; text-align: center; } + .stage .sub { max-width: 76px; font-size: calc(9.5px * var(--fs-scale)); } + + .board { display: block; } + .main-col, aside { width: 100%; min-width: 0; } + aside { margin-top: 20px; } + aside .panel + .panel { margin-top: 14px; } + + .script-card { + width: 100%; + max-width: 700px; + padding: 28px 32px 26px; + } + .filmstrip { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + padding-left: 4px; + padding-right: 4px; + } + .section-title { flex-wrap: wrap; } + .section-title .meta { margin-left: 0; } +} + +@media (max-width: 520px) { + .wrap { padding: 0 12px 52px; } + .slate { padding-top: 14px; } + .clapper { width: 30px; height: 23px; } + .slate .wordmark { font-size: calc(10px * var(--fs-scale)); } + .slate h1 { font-size: calc(15px * var(--fs-scale)); letter-spacing: .06em; } + .chip { font-size: calc(9.5px * var(--fs-scale)); padding: 3px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } + .live { font-size: calc(10px * var(--fs-scale)); letter-spacing: .1em; } + .cost { width: 100%; } + .cost .bar { width: 100%; } + + .rail { margin: 0 -12px; padding-left: 12px; padding-right: 12px; } + .stage { flex-basis: 74px; } + .stage .name, .stage .sub { max-width: 68px; } + + .script-card { + padding: 24px 20px 28px; + border-radius: 5px; + } + .script-approved { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; } + .script-card .sp-title { font-size: calc(14px * var(--fs-scale)); padding-right: 58px; } + .script-card .sp-meta { margin-bottom: 18px; } + .script-card .sp-slug .tc { float: none; display: block; margin-top: 2px; } + .script-card .sp-expand { right: 12px; bottom: 10px; } + + .panel-head { flex-wrap: wrap; } + .panel-head .meta { margin-left: 0; } + .drawer .drawer-head { flex-wrap: wrap; } + .drawer pre { font-size: calc(10.5px * var(--fs-scale)); } + .scene-card { max-width: calc(100vw - 42px); } +} diff --git a/backlot/ui/board.html b/backlot/ui/board.html new file mode 100644 index 00000000..9b9a2488 --- /dev/null +++ b/backlot/ui/board.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Backlot + + + +
+ + + + + diff --git a/backlot/ui/board.js b/backlot/ui/board.js new file mode 100644 index 00000000..f8ed62be --- /dev/null +++ b/backlot/ui/board.js @@ -0,0 +1,830 @@ +// Backlot project board — renders BoardState and stays live via SSE. + +import { + STAGE_ICONS, el, fmtAgo, fmtClock, fmtDuration, fmtMoney, + getJSON, mediaURL, subscribe, thumbURL, waveBars, +} from "/ui/lib.js"; + +const rawProjectPath = location.pathname.split("/p/")[1] || ""; +const projectId = decodeURIComponent(rawProjectPath); +const encodedProjectId = encodeURIComponent(projectId); +const app = document.getElementById("app"); +const modal = document.getElementById("modal"); +const player = document.getElementById("player"); + +let state = null; +let selectedStage = null; // stage drawer open for this stage name +let activeRender = 0; +let replay = null; // {t0, t1, t, playing} — replay mode when non-null +let firstPaint = true; + +// --------------------------------------------------------------------------- +// header slate +// --------------------------------------------------------------------------- + +function renderSlate(s) { + const board = s.storyboard; + const chips = [ + el("span", { class: "chip" }, `${s.pipeline.pipeline_type} pipeline`), + board && board.total_duration_seconds + ? el("span", { class: "chip" }, `${board.scenes.length} scenes · ${fmtDuration(board.total_duration_seconds)}`) + : null, + s.style_playbook ? el("span", { class: "chip" }, s.style_playbook) : null, + ]; + + const awaiting = s.stages.find((x) => x.status === "awaiting_human"); + const inProgress = s.stages.find((x) => x.status === "in_progress"); + const stalled = s.stages.find((x) => x.stalled); + let liveEl; + if (awaiting) { + liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "◈ AWAITING YOU"); + } else if (stalled) { + liveEl = el("span", { class: "live", style: "color:var(--red)" }, + el("span", { class: "dot", style: "background:var(--red);animation:none" }), "⚠ STALLED?"); + } else if (s.live || inProgress) { + liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "LIVE"); + } else { + liveEl = el("span", { class: "live idle" }, el("span", { class: "dot" }), + `IDLE${s.last_activity ? " · " + fmtAgo(s.last_activity).toUpperCase() : ""}`); + } + + const cost = el("div", { class: "cost" }); + if (s.cost) { + const spent = s.cost.total_spent_usd ?? 0; + const budget = spent + (s.cost.budget_remaining_usd ?? 0); + const hasBudget = s.cost.budget_remaining_usd != null; + const pct = hasBudget && budget > 0 ? Math.min(100, (spent / budget) * 100) : 0; + cost.append(el("div", { class: "nums" }, el("b", {}, fmtMoney(spent)), + hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : "")); + if (hasBudget) { + cost.append(el("div", { class: "bar" }, el("i", { + class: pct > 90 ? "crit" : pct > 75 ? "warn" : "", style: `width:${pct}%`, + }))); + } + cost.append(el("div", { class: "label" }, "generation spend")); + } + + return el("header", { class: "slate" }, + el("div", { class: "clapper" }), + el("div", {}, + el("a", { class: "wordmark", href: "/", style: "text-decoration:none" }, "Backlot"), + el("h1", {}, s.title), + ), + ...chips, + el("div", { class: "spacer" }), + liveEl, + cost, + ); +} + +// --------------------------------------------------------------------------- +// stage rail +// --------------------------------------------------------------------------- + +function stageSub(st) { + if (st.status === "awaiting_human") return "awaiting your approval\nreply in chat to continue"; + if (st.status === "in_progress" && st.stalled) { + return `stalled? no activity for ${st.stalled_minutes}m\nask the agent for status`; + } + if (st.status === "in_progress" && st.partial_progress) { + const done = st.partial_progress.completed_scene_ids; + if (Array.isArray(done)) return `${done.length} scene${done.length === 1 ? "" : "s"} done`; + return "in progress"; + } + if (st.status === "in_progress") return "in progress"; + if (st.status === "failed") return st.error ? String(st.error).slice(0, 60) : "failed"; + if (st.timestamp) { + const approved = st.gated && st.human_approved ? " · approved" : ""; + return fmtClock(st.timestamp) + approved; + } + return ""; +} + +function renderRail(s) { + const rail = el("nav", { class: "rail" }); + let pendingIndex = 1; + for (const st of s.stages) { + const cls = st.status === "completed" ? "done" + : st.status === "in_progress" ? (st.stalled ? "active stalled" : "active") + : st.status === "awaiting_human" ? "await" + : st.status === "failed" ? "failed" : ""; + const icon = STAGE_ICONS[st.status] || String(pendingIndex); + if (!STAGE_ICONS[st.status]) pendingIndex += 1; + const node = el("div", { + class: `stage ${cls}${selectedStage === st.name ? " selected" : ""}${st.undeclared ? " undeclared" : ""}`, + title: st.undeclared ? `"${st.name}" ran but isn't declared by this pipeline's manifest` : null, + onclick: () => toggleDrawer(st.name), + }, + el("span", { class: "line" }), + el("span", { class: "node" }, icon), + el("span", { class: "name" }, st.name), + el("span", { class: "sub", style: "white-space:pre-line" }, + st.undeclared ? `${stageSub(st)}\nunlisted`.trim() : stageSub(st)), + ); + rail.append(node); + } + return rail; +} + +function toggleDrawer(stageName) { + selectedStage = selectedStage === stageName ? null : stageName; + render(); +} + +const STAGE_ARTIFACTS = { + research: ["research_brief"], + proposal: ["proposal_packet"], + idea: ["brief"], + script: ["script"], + scene_plan: ["scene_plan"], + assets: ["asset_manifest"], + edit: ["edit_decisions"], + compose: ["render_report", "final_review"], + publish: ["publish_log"], +}; + +function renderDrawer(s) { + if (!selectedStage) return null; + const st = s.stages.find((x) => x.name === selectedStage); + if (!st) return null; + + const body = el("div", { class: "drawer-body" }); + + if (st.review) { + body.append(el("div", { class: "findings", style: "margin-bottom:12px" }, + el("span", { class: `f ${st.review.critical ? "crit" : ""}` }, `${st.review.critical ?? 0} critical`), + el("span", { class: `f ${st.review.suggestions ? "sugg" : ""}` }, `${st.review.suggestions ?? 0} suggestions`), + el("span", { class: "f" }, `${st.review.nitpicks ?? 0} nitpicks`), + typeof st.review.summary === "string" ? el("span", { style: "font-size:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, st.review.summary) : null, + )); + } + + const names = STAGE_ARTIFACTS[st.name] || []; + let shown = false; + for (const name of names) { + const artifact = s.artifacts[name]; + if (!artifact) continue; + shown = true; + body.append( + el("div", { class: "d-cat", style: "font-family:var(--mono);font-size:calc(9.5px * var(--fs-scale));color:var(--text-3);letter-spacing:.1em;text-transform:uppercase;margin:6px 0 4px" }, name), + el("pre", {}, JSON.stringify(artifact, null, 2)), + ); + } + if (!shown) { + body.append(el("div", { class: "hint" }, + st.status === "pending" ? "This stage hasn't run yet." : "No canonical artifact found on disk for this stage.")); + } + + return el("div", { class: "drawer" }, + el("div", { class: "drawer-head" }, + el("h3", {}, `${st.name} — ${st.status}`), + st.gate_skipped ? el("span", { class: "gate-chip" }, "⚑ GATE SKIPPED") : null, + st.versions > 1 ? el("span", { class: "ver-chip" }, `v${st.versions}`) : null, + st.timestamp ? el("span", { class: "meta", style: "font-family:var(--mono);font-size:calc(10.5px * var(--fs-scale));color:var(--text-3)" }, st.timestamp) : null, + el("span", { class: "close", onclick: () => toggleDrawer(st.name) }, "CLOSE ✕"), + ), + body, + ); +} + +// --------------------------------------------------------------------------- +// script card +// --------------------------------------------------------------------------- + +function scriptSections(script, limit) { + const sections = script.sections || []; + const shown = limit ? sections.slice(0, limit) : sections; + const nodes = []; + for (const sec of shown) { + nodes.push(el("div", { class: "sp-slug" }, + `${(sec.id || "").toUpperCase()} — ${sec.label || "Section"} `, + el("span", { class: "tc" }, `${fmtDuration(sec.start_seconds)} – ${fmtDuration(sec.end_seconds)}`))); + if (sec.text) nodes.push(el("div", { class: "sp-action" }, sec.text)); + if (sec.speaker_directions) nodes.push(el("div", { class: "sp-paren" }, `(${sec.speaker_directions})`)); + const cues = sec.enhancement_cues || []; + if (cues.length) { + nodes.push(el("div", { style: "margin-left:42px" }, + cues.map((c) => el("span", { class: "sp-cue" }, `▸ ${c.type} · ${String(c.description || "").slice(0, 60)}`)))); + } + } + if (limit && sections.length > limit) { + nodes.push(el("div", { class: "sp-fade" }, `… ${sections.length - limit} more sections`)); + } + return nodes; +} + +function renderScriptCard(s) { + const script = s.artifacts.script; + if (!script) return null; + const scriptStage = s.stages.find((x) => x.name === "script"); + const approved = scriptStage && scriptStage.status === "completed"; + + const card = el("div", { class: "script-card", title: "Click to expand full script", onclick: openScriptModal }, + approved ? el("span", { class: "script-approved" }, "APPROVED") : null, + el("div", { class: "sp-title" }, script.title || s.title), + el("div", { class: "sp-meta" }, + `script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`), + ...scriptSections(script, 4), + el("span", { class: "sp-expand" }, "⤢ EXPAND SCRIPT"), + ); + return card; +} + +function openScriptModal() { + const script = state && state.artifacts.script; + if (!script) return; + modal.innerHTML = ""; + modal.append( + el("span", { class: "modal-close", onclick: closeModal }, "ESC · CLOSE"), + el("div", { class: "modal-page" }, + el("div", { class: "script-card", style: "cursor:default" }, + el("div", { class: "sp-title" }, script.title || state.title), + el("div", { class: "sp-meta" }, + `script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`), + ...scriptSections(script, 0), + el("div", { class: "sp-fade" }, "END"), + )), + ); + modal.classList.add("open"); +} + +function openNarrModal(card) { + modal.innerHTML = ""; + const meta = [sceneLabel(card.id), card.section_label, fmtDuration(card.duration_seconds)] + .filter(Boolean).join(" · "); + modal.append( + el("span", { class: "modal-close", onclick: closeModal }, "ESC · CLOSE"), + el("div", { class: "modal-page" }, + el("div", { class: "script-card", style: "cursor:default" }, + el("div", { class: "sp-meta" }, meta), + card.narration ? el("div", { class: "sp-action", style: "margin-left:0" }, card.narration) : null, + card.shot_intent ? el("div", { class: "sp-paren", style: "margin-left:0" }, `Intent — ${card.shot_intent}`) : null, + card.description ? el("div", { class: "sp-paren", style: "margin-left:0" }, card.description) : null, + )), + ); + modal.classList.add("open"); +} + +function closeModal() { modal.classList.remove("open"); } +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); }); +modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); }); + +// --------------------------------------------------------------------------- +// right rail: decisions, activity +// --------------------------------------------------------------------------- + +function renderDecisions(s) { + const log = s.artifacts.decision_log; + const decisions = (log && log.decisions) || []; + if (!decisions.length) return null; + const body = el("div", { class: "panel-body" }); + // Collapse by category+subject: a decision that changed mid-run (e.g. voice + // openai_onyx → chirp3) is superseded by the later entry — show the CURRENT + // choice, not the first one recorded, and mark that it was revised. + const current = new Map(); + decisions.forEach((d, i) => { + const key = `${d.category || "decision"}::${d.subject || ""}`; + const prev = current.get(key); + current.set(key, { d, order: i, revised: prev ? prev.revised + 1 : 0 }); + }); + const shown = [...current.values()].sort((a, b) => b.order - a.order).slice(0, 8); + for (const { d, revised } of shown) { + const selLabel = (() => { + // Prefer the human label of the selected option over its bare id. + const opt = (d.options_considered || []).find((o) => (o.option_id ?? o.label) === d.selected); + return (opt && opt.label) || d.selected || ""; + })(); + const alts = (d.options_considered || []) + .filter((o) => (o.option_id ?? o.label) !== d.selected && (o.option_id || o.label)); + body.append(el("div", { class: "decision" }, + el("div", { class: "d-cat" }, `${d.category || "decision"}${d.confidence ? ` · ${d.confidence}` : ""}`, + revised ? el("span", { class: "d-revised" }, " · revised") : null), + el("div", { class: "d-pick" }, `${d.subject || ""} `, el("span", { class: "arrow" }, "→"), ` ${selLabel}`), + d.reason ? el("div", { class: "d-why" }, d.reason) : null, + alts.length ? el("div", { class: "d-alt" }, "also considered: ", + alts.slice(0, 3).map((o, i) => [i ? " · " : "", el("s", {}, o.label || o.option_id)]).flat()) : null, + )); + } + return el("div", { class: "panel" }, + el("div", { class: "panel-head" }, el("h2", {}, "Decisions"), el("span", { class: "meta" }, "decision_log.json")), + body); +} + +function renderActivity(s) { + const events = s.events || []; + if (!events.length) return null; + const body = el("div", { class: "panel-body" }); + // A start is "running" only until a later finish/error for the same + // tool+scene closes it — closed starts are dropped (the finish row tells + // the story), unmatched starts render as live. Counted (not keyed-single) + // so parallel runs of the same tool on the same scene stay visible. + const open = new Map(); // key -> {count, ev} + const rows = []; + for (const ev of events) { + const key = `${ev.tool}:${ev.scene_id || ""}`; + if (ev.event === "start") { + const slot = open.get(key) || { count: 0, ev }; + slot.count += 1; + slot.ev = ev; + open.set(key, slot); + } else { + const slot = open.get(key); + if (slot) { + slot.count -= 1; + if (slot.count <= 0) open.delete(key); + } + rows.push(ev); + } + } + for (const slot of open.values()) rows.push(slot.ev); + rows.sort((a, b) => String(a.ts).localeCompare(String(b.ts))); + for (const ev of rows.slice(-10).reverse()) { + let statusEl; + if (ev.event === "finish") { + statusEl = el("span", { class: `status ${ev.success === false ? "err" : "ok"}` }, + `${ev.success === false ? "✕" : "✓"}${ev.duration_s != null ? ` ${ev.duration_s.toFixed ? ev.duration_s.toFixed(1) : ev.duration_s}s` : ""}${ev.cost_usd ? ` ${fmtMoney(ev.cost_usd)}` : ""}`); + } else if (ev.event === "error") { + statusEl = el("span", { class: "status err" }, "✕"); + } else { + statusEl = el("span", { class: "status run" }, "● running"); + } + body.append(el("div", { class: "act-row" }, + el("span", { class: "t" }, fmtClock(ev.ts)), + el("span", { class: "tool" }, ev.tool || ""), + el("span", { class: "target" }, ev.scene_id || ""), + statusEl, + )); + } + return el("div", { class: "panel" }, + el("div", { class: "panel-head" }, el("h2", {}, "Activity"), el("span", { class: "meta" }, "events.jsonl")), + body); +} + +// --------------------------------------------------------------------------- +// storyboard filmstrip +// --------------------------------------------------------------------------- + +function sceneLabel(id) { + // "sc4" → "SC 04", "scene-11" → "SC 11", anything else → uppercased id + const m = String(id).match(/(\d+)\s*$/); + if (m) return `SC ${m[1].padStart(2, "0")}`; + return String(id).toUpperCase().slice(0, 10); +} + +function sceneCard(s, card) { + const dur = card.duration_seconds; + const width = Math.max(132, Math.min(300, 70 + (dur || 3) * 26)); + const wrap = el("div", { class: "scene-card", style: `width:${width}px` }); + + const slate = el("div", { class: "sc-slate" }, + el("span", { class: "num" }, sceneLabel(card.id)), + card.takes.length > 1 ? el("span", { class: "take" }, `T${card.takes.length}`) : null, + card.hero_moment ? el("span", { class: "hero" }, "★ HERO") : null, + el("span", { class: "dur" }, fmtDuration(dur)), + ); + wrap.append(slate); + + // visual slot + let thumb; + if (card.generating) { + thumb = el("div", { class: "thumb generating" }, + el("div", { class: "shimmer" }), + el("div", { class: "gen-label" }, + el("span", {}, "◉ GENERATING"), + el("span", { class: "sub" }, card.generating_tool || ""))); + } else if (card.visual && card.visual.exists) { + const v = card.visual; + const badge = [v.model || v.source_tool, v.cost_usd != null ? fmtMoney(v.cost_usd) : null, + v.quality_score != null ? `q ${v.quality_score}` : null].filter(Boolean).join(" · "); + if (v.type === "video") { + thumb = el("div", { class: "thumb approved" }, + el("video", { src: mediaURL(s.project_id, v.path), muted: "", preload: "metadata", playsinline: "" }), + el("span", { class: "play" }, "▶"), + badge ? el("span", { class: "badge" }, badge) : null); + thumb.onclick = () => { + const vid = thumb.querySelector("video"); + if (vid.paused) vid.play(); else vid.pause(); + }; + } else { + const img = el("img", { src: thumbURL(s.project_id, v.path, 640), loading: "lazy", alt: "" }); + // A thumbnail that fails to load must never show a broken-image icon — + // fall back to the shot spec in place (F: broken links). + img.onerror = () => { + const t = img.closest(".thumb"); + if (!t) return; + t.className = "thumb spec"; + t.innerHTML = ""; + t.append(el("div", { class: "spec-in" }, + el("div", { class: "spec-desc" }, card.description || "asset unavailable"), + el("div", { class: "spec-shot" }, [card.framing, card.movement].filter(Boolean).join(" · ").slice(0, 70)))); + }; + thumb = el("div", { class: "thumb approved" }, img, + v.snapshot ? el("span", { class: "badge" }, "snapshot") : (badge ? el("span", { class: "badge" }, badge) : null)); + } + } else if (card.type === "animation") { + // Bespoke/atelier scene with no snapshot yet — name it as such rather + // than "no asset yet" (the composition IS the asset). + thumb = el("div", { class: "thumb spec bespoke" }, + el("div", { class: "spec-in" }, + el("span", { class: "bespoke-tag" }, "◆ BESPOKE"), + el("div", { class: "spec-desc" }, card.description || ""), + el("div", { class: "spec-shot" }, "hand-authored composition"))); + } else if (card.visual && !card.visual.exists) { + thumb = el("div", { class: "thumb missing" }, + el("div", { class: "spec-in" }, + el("span", { class: "warn-ic" }, "⚑"), + el("div", { class: "spec-desc" }, "asset in manifest, file missing"), + el("div", { class: "spec-shot" }, card.visual.path || ""))); + } else if (card.type === "text_card") { + thumb = el("div", { class: "thumb textcard" }, + el("div", { class: "tc-copy" }, (card.narration || card.description || "").slice(0, 48))); + } else if (card.required_assets.length) { + thumb = el("div", { class: "thumb missing" }, + el("div", { class: "spec-in" }, + el("span", { class: "warn-ic" }, "⚑"), + el("div", { class: "spec-desc" }, "no asset yet"), + el("div", { class: "spec-shot" }, (card.required_assets[0].description || "").slice(0, 60)))); + } else { + thumb = el("div", { class: "thumb spec" }, + el("div", { class: "spec-in" }, + el("div", { class: "spec-desc" }, card.description || ""), + el("div", { class: "spec-shot" }, [card.framing, card.movement].filter(Boolean).join(" · ").slice(0, 70)))); + } + wrap.append(thumb); + + // shot language chips + const sl = card.shot_language; + if (sl) { + wrap.append(el("div", { class: "shotchips", style: "display:flex;flex-wrap:wrap;gap:4px;padding:7px 2px 0" }, + [sl.shot_size, sl.camera_movement, sl.lens_mm ? `${sl.lens_mm}mm` : null, sl.lighting_key] + .filter(Boolean) + .map((t) => el("span", { style: "font-family:var(--mono);font-size:calc(8.5px * var(--fs-scale));letter-spacing:.04em;color:#62626c;border:1px solid #212129;border-radius:3px;padding:1px 5px" }, String(t).replaceAll("_", " "))))); + } + + // takes drawer + if (card.takes.length > 1) { + const takes = el("div", { class: "takes" }); + card.takes.forEach((t, i) => { + const isActive = card.visual && ( + t === card.visual + || (t.path && t.path === card.visual.path) + || (t.id && t.id === card.visual.id) + ); + const tk = el("span", { class: `tk${isActive ? " active" : ""}`, title: `take ${i + 1}` }); + if (t.exists && t.type === "image") tk.append(el("img", { src: thumbURL(s.project_id, t.path, 320), loading: "lazy", alt: "" })); + takes.append(tk); + }); + takes.append(el("span", { class: "tk-label" }, `${card.takes.length} TAKES`)); + wrap.append(takes); + } + + // narration + audio — clickable to read in full (F: narration text cut off) + if (card.narration) { + const long = card.narration.length > 90; + wrap.append(el("div", { + class: `narr${long ? " clip" : ""}`, + title: "Click to read the full narration", + onclick: () => openNarrModal(card), + }, card.narration, long ? el("span", { class: "narr-more" }, "⤢") : null)); + } else if (card.shot_intent || card.description) { + wrap.append(el("div", { class: "narr tc-note" }, (card.shot_intent || card.description || "").slice(0, 110))); + } + const narrAudio = card.audio.find((a) => a.exists && (a.type === "narration" || a.type === "audio")); + if (narrAudio) { + const wave = el("div", { class: "wave", style: "cursor:pointer", title: "Play narration" }); + waveBars(wave, card.id + narrAudio.path); + wave.append(el("span", { class: "wv-time" }, narrAudio.duration_seconds ? fmtDuration(narrAudio.duration_seconds) : "♪")); + wave.onclick = () => { + player.src = mediaURL(s.project_id, narrAudio.path); + player.play(); + }; + wrap.append(wave); + } + return wrap; +} + +function renderStoryboard(s) { + const board = s.storyboard; + if (!board) return null; + const strip = el("div", { class: "filmstrip" }); + for (const card of board.scenes) strip.append(sceneCard(s, card)); + return el("div", {}, + el("div", { class: "section-title" }, "Storyboard", + el("span", { class: "meta" }, + `${board.scenes.length} scenes${board.total_duration_seconds ? ` · ${fmtDuration(board.total_duration_seconds)}` : ""} · card width ∝ duration`)), + el("div", { class: "strip-outer" }, strip)); +} + +// --------------------------------------------------------------------------- +// renders + degraded media +// --------------------------------------------------------------------------- + +function renderRenders(s) { + const renders = s.media.renders; + if (!renders.length) return null; + if (activeRender >= renders.length) activeRender = 0; + const current = renders[activeRender]; + // Full re-renders (every SSE refresh) must not reset an in-progress + // watch: carry playback position/state over to the recreated element. + const prev = document.querySelector(".render-hero video"); + const src = mediaURL(s.project_id, current.path); + const video = el("video", { src, controls: "", preload: "none" }); + // Click the frame to start playback (controls handle pause/scrub) — the + // big player was inert to a click on the picture itself. + video.addEventListener("click", () => { if (video.paused) video.play().catch(() => {}); }); + if (prev && prev.getAttribute("src") === src && (prev.currentTime > 0 || !prev.paused)) { + const t = prev.currentTime; + const wasPlaying = !prev.paused && !prev.ended; + video.addEventListener("loadedmetadata", () => { video.currentTime = t; }, { once: true }); + video.setAttribute("preload", "metadata"); + if (wasPlaying) video.autoplay = true; + } + const versions = el("div", { class: "render-meta" }, + renders.map((r, i) => el("span", { + class: `v${i === activeRender ? " active" : ""}`, + onclick: () => { activeRender = i; render(); }, + }, `${r.path.split("/").pop()}${r.at_root ? " · root" : ""}`)), + el("span", { style: "margin-left:auto" }, `${(current.size / 1048576).toFixed(1)} MB`), + ); + return el("div", {}, + el("div", { class: "section-title" }, "Renders", + el("span", { class: "meta" }, `${renders.length} version${renders.length === 1 ? "" : "s"}`)), + el("div", { class: "render-hero" }, video), + versions); +} + +function renderFoundMedia(s) { + // Degraded view: show discovered snapshots when there's no storyboard. + if (s.storyboard || !s.media.snapshots.length) return null; + const grid = el("div", { class: "found-grid" }); + for (const snap of s.media.snapshots.slice(0, 12)) { + grid.append(el("div", { class: "thumb" }, + el("img", { src: thumbURL(s.project_id, snap.path, 640), loading: "lazy", alt: "" }))); + } + return el("div", {}, + el("div", { class: "section-title" }, "What the watcher found", + el("span", { class: "meta" }, "snapshots / verification frames")), + grid); +} + +function renderNoState(s) { + if (s.has_pipeline_state) return null; + return el("div", { class: "notice", style: "border-color:#2b2b33;background:var(--surface-2);color:var(--text-3)" }, + el("span", { style: "font-size:calc(15px * var(--fs-scale))" }, "◌"), + el("span", {}, + el("b", { style: "color:var(--text-2)" }, "No pipeline state. "), + "This project has no checkpoints — Backlot is showing what it found on disk. ", + "Runs that follow the checkpoint protocol get the full board.")); +} + +function renderAwaitingNotice(s) { + const awaiting = s.stages.find((x) => x.status === "awaiting_human"); + if (!awaiting) return null; + return el("div", { class: "notice" }, + el("span", { style: "font-size:calc(16px * var(--fs-scale))" }, "◈"), + el("span", {}, + el("b", {}, `The ${awaiting.name} stage is waiting for your review. `), + "The agent is paused at this gate — reply ", el("b", {}, "in chat"), " to approve or request changes.")); +} + +// --------------------------------------------------------------------------- +// replay — scrub a completed run from its timestamps +// --------------------------------------------------------------------------- + +// Python writers emit tz-aware UTC isoformat, but treat tz-naive strings as +// UTC too — mixing local-parsed and UTC-parsed timestamps would skew replay +// ordering by the user's UTC offset. +const ts = (iso) => { + if (!iso) return null; + let s = String(iso); + if (!/(Z|[+-]\d{2}:?\d{2})$/.test(s)) s += "Z"; + const t = Date.parse(s); + return Number.isFinite(t) ? t : null; +}; + +function replayBounds(s) { + const moments = []; + for (const st of s.stages) { + for (const h of st.history_entries || []) { + const t = ts(h.timestamp); + if (t) moments.push(t); + } + } + for (const ev of s.events || []) { + const t = ts(ev.ts); + if (t) moments.push(t); + } + if (moments.length < 2) return null; + return { t0: Math.min(...moments), t1: Math.max(...moments) }; +} + +function stateAt(s, T) { + const view = structuredClone(s); + for (const st of view.stages) { + const past = (st.history_entries || []).filter((h) => ts(h.timestamp) != null && ts(h.timestamp) <= T); + if (!past.length) { + st.status = "pending"; st.review = null; st.timestamp = null; + st.gate_skipped = false; st.partial_progress = null; + } else { + const cur = past[past.length - 1]; + st.status = cur.status || "pending"; + st.timestamp = cur.timestamp; + } + } + view.events = (view.events || []).filter((ev) => ts(ev.ts) != null && ts(ev.ts) <= T); + + // Storyboard: visuals appear as their scene finishes (events) or when the + // assets stage has completed as of T (legacy runs without events). + if (view.storyboard) { + const assetsStage = view.stages.find((x) => x.name === "assets"); + const assetsDone = assetsStage && assetsStage.status === "completed"; + const finished = new Set(); + const startedNow = new Map(); + for (const ev of view.events) { + if (!ev.scene_id) continue; + if (ev.event === "finish") { finished.add(ev.scene_id); startedNow.delete(ev.scene_id); } + else if (ev.event === "start") startedNow.set(ev.scene_id, ev); + else if (ev.event === "error") startedNow.delete(ev.scene_id); + } + const scenePlanStage = view.stages.find((x) => x.name === "scene_plan"); + const scenePlanDone = scenePlanStage && ["completed", "awaiting_human"].includes(scenePlanStage.status); + if (!scenePlanDone) { + view.storyboard = null; + } else { + for (const card of view.storyboard.scenes) { + const visible = assetsDone || finished.has(card.id); + if (!visible) { card.visual = null; card.takes = []; card.audio = []; } + card.generating = startedNow.has(card.id); + card.generating_tool = (startedNow.get(card.id) || {}).tool; + } + } + } + // Final artifacts hide until their stage happened — for every project + // shape, storyboard or not (a degraded run must not show the finished + // movie before its stages ran). + const scriptStage = view.stages.find((x) => x.name === "script"); + if (!(scriptStage && ["completed", "awaiting_human"].includes(scriptStage.status))) { + delete view.artifacts.script; + } + const composeStage = view.stages.find((x) => x.name === "compose"); + if (!(composeStage && composeStage.status === "completed")) { + view.media.renders = []; + } + return view; +} + +function renderReplayBar(s) { + const bounds = replayBounds(s); + if (!bounds) return null; + if (!replay) { + // collapsed: just the entry button + return el("div", { class: "replay-bar", style: "justify-content:flex-end" }, + el("span", { class: "rp-time" }, "scrub the whole run"), + el("span", { class: "rp-btn", onclick: startReplay }, "▶ REPLAY RUN")); + } + const pos = (replay.t - replay.t0) / Math.max(1, replay.t1 - replay.t0); + const timeLabel = el("span", { class: "rp-time" }, + new Date(replay.t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })); + const setT = (value) => { + replay.t = replay.t0 + (Number(value) / 1000) * (replay.t1 - replay.t0); + timeLabel.textContent = new Date(replay.t) + .toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + }; + return el("div", { class: "replay-bar" }, + el("span", { class: "rp-btn", onclick: toggleReplayPlay }, replay.playing ? "❚❚" : "▶"), + el("input", { + type: "range", min: "0", max: "1000", value: String(Math.round(pos * 1000)), + // A full render() would destroy this slider mid-drag: while dragging, + // only pause + track the time label; re-render the board on release. + onpointerdown: () => { replay.playing = false; }, + oninput: (e) => setT(e.target.value), + onchange: (e) => { setT(e.target.value); render(); }, + }), + timeLabel, + el("span", { class: "rp-btn", onclick: stopReplay }, "✕ LIVE"), + ); +} + +let replayTimer = null; + +function startReplay() { + const bounds = replayBounds(state); + if (!bounds) return; + replay = { ...bounds, t: bounds.t0, playing: true }; + document.body.classList.add("replaying"); + scheduleTick(); + render(); +} + +function stopReplay() { + replay = null; + clearTimeout(replayTimer); + document.body.classList.remove("replaying"); + render(); +} + +function toggleReplayPlay() { + if (!replay) return; + replay.playing = !replay.playing; + if (replay.playing) scheduleTick(); + render(); +} + +function scheduleTick() { + // Single pending tick, ever — rapid pause/play must not stack chains. + clearTimeout(replayTimer); + replayTimer = setTimeout(tickReplay, 100); +} + +function tickReplay() { + if (!replay || !replay.playing) return; + // A full run replays in ~20 seconds regardless of real duration + // (10 renders/second — full re-render per tick, keep it modest). + const step = (replay.t1 - replay.t0) / 200; + replay.t = Math.min(replay.t1, replay.t + step); + if (replay.t >= replay.t1) replay.playing = false; + render(); + if (replay.playing) scheduleTick(); +} + +// --------------------------------------------------------------------------- +// page assembly +// --------------------------------------------------------------------------- + +function render() { + if (!state) return; + const s = replay ? stateAt(state, replay.t) : state; + document.title = `Backlot — ${s.title}`; + document.body.classList.toggle("first", firstPaint); + firstPaint = false; + app.innerHTML = ""; + app.append(renderSlate(s)); + app.append(renderRail(s)); + const replayBar = renderReplayBar(state); + if (replayBar) app.append(replayBar); + const drawer = renderDrawer(s); + if (drawer) app.append(drawer); + const awaitingNotice = renderAwaitingNotice(s); + if (awaitingNotice) app.append(awaitingNotice); + const noState = renderNoState(s); + if (noState) app.append(noState); + + const main = el("div", { class: "main-col" }); + const script = renderScriptCard(s); + if (script) main.append(script); + const aside = el("aside", {}); + const decisions = renderDecisions(s); + const activity = renderActivity(s); + if (decisions) aside.append(decisions); + if (activity) aside.append(activity); + + if (script || decisions || activity) { + app.append(el("div", { class: "board" }, main, aside)); + } + + const storyboard = renderStoryboard(s); + if (storyboard) app.append(storyboard); + const found = renderFoundMedia(s); + if (found) app.append(found); + const renders = renderRenders(s); + if (renders) app.append(renders); +} + +// Defensive normalization (F-02): the server contract guarantees these +// fields, but a sparse/legacy payload must degrade, never crash the board. +function normalize(s) { + s.pipeline = s.pipeline || { pipeline_type: "unknown", stages: [], known: false }; + s.stages = Array.isArray(s.stages) ? s.stages : []; + s.artifacts = s.artifacts || {}; + s.media = s.media || {}; + s.media.renders = Array.isArray(s.media.renders) ? s.media.renders : []; + s.media.snapshots = Array.isArray(s.media.snapshots) ? s.media.snapshots : []; + s.media.music = Array.isArray(s.media.music) ? s.media.music : []; + s.events = Array.isArray(s.events) ? s.events : []; + if (s.storyboard && Array.isArray(s.storyboard.scenes)) { + for (const c of s.storyboard.scenes) { + c.takes = Array.isArray(c.takes) ? c.takes : []; + c.audio = Array.isArray(c.audio) ? c.audio : []; + c.required_assets = Array.isArray(c.required_assets) ? c.required_assets : []; + } + } else { + s.storyboard = null; + } + return s; +} + +async function refresh() { + state = normalize(await getJSON(`/api/project/${encodeURIComponent(projectId)}/state`)); + render(); +} + +refresh().catch((err) => { + app.innerHTML = ""; + app.append(el("div", { class: "empty", style: "margin-top:80px" }, + el("div", { class: "big" }, "PROJECT NOT FOUND"), + el("div", {}, String(err)))); +}); +// ?static=1 disables the live feed (screenshots, static exports). +if (!new URLSearchParams(location.search).has("static")) { + subscribe(`/api/project/${encodeURIComponent(projectId)}/events`, () => refresh().catch(console.error)); +} diff --git a/backlot/ui/index.html b/backlot/ui/index.html new file mode 100644 index 00000000..b79219c9 --- /dev/null +++ b/backlot/ui/index.html @@ -0,0 +1,26 @@ + + + + + +Backlot — Library + + + +
+
+
+
+ Backlot +

Library

+
+ +
+ IDLE +
+
+ +
+ + + diff --git a/backlot/ui/lib.js b/backlot/ui/lib.js new file mode 100644 index 00000000..5a93d4e9 --- /dev/null +++ b/backlot/ui/lib.js @@ -0,0 +1,103 @@ +// Shared helpers for the Backlot UI. + +export async function getJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} ${url}`); + return res.json(); +} + +export function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v == null) continue; + if (k === "class") node.className = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + } + for (const child of children.flat()) { + if (child == null) continue; + node.append(child.nodeType ? child : document.createTextNode(String(child))); + } + return node; +} + +export function fmtDuration(seconds) { + const n = Number(seconds); + if (seconds == null || !Number.isFinite(n)) return ""; + const s = Math.max(0, Math.round(n)); + const m = Math.floor(s / 60); + return `${m}:${String(s % 60).padStart(2, "0")}`; +} + +export function fmtMoney(v) { + const n = Number(v); + if (v == null || !Number.isFinite(n)) return "—"; + return `$${n.toFixed(2)}`; +} + +export function fmtAgo(epochSeconds) { + if (!epochSeconds) return ""; + const diff = Date.now() / 1000 - epochSeconds; + if (diff < 90) return "just now"; + if (diff < 3600) return `${Math.round(diff / 60)}m ago`; + if (diff < 86400) return `${Math.round(diff / 3600)}h ago`; + return `${Math.round(diff / 86400)}d ago`; +} + +export function fmtClock(iso) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + } catch { + return ""; + } +} + +export function mediaURL(projectId, relPath) { + return `/media/${encodeURIComponent(projectId)}/${relPath.split("/").map(encodeURIComponent).join("/")}`; +} + +// Downscaled cached JPEG for images (full media only in players/lightbox). +export function thumbURL(projectId, relPath, w = 640) { + return `/thumb/${encodeURIComponent(projectId)}/${relPath.split("/").map(encodeURIComponent).join("/")}?w=${w}`; +} + +// Subscribe to a server-sent change feed; call onChange (debounced) per burst. +export function subscribe(url, onChange) { + let timer = null; + const source = new EventSource(url); + source.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data); + if (data.type !== "change") return; + } catch { + return; + } + clearTimeout(timer); + timer = setTimeout(onChange, 250); + }; + source.onerror = () => { /* EventSource auto-reconnects */ }; + return source; +} + +// Deterministic pseudo-waveform bars (seeded by a string). +export function waveBars(container, seedStr, count = 26, maxH = 14) { + let seed = 0; + for (const c of seedStr || "wave") seed = (seed * 31 + c.charCodeAt(0)) % 2147483647; + seed = seed || 7; + container.innerHTML = ""; + for (let i = 0; i < count; i++) { + seed = (seed * 16807) % 2147483647; + const h = 3 + ((seed % 100) / 100) * maxH * (0.55 + 0.45 * Math.sin(i / 5)); + const bar = document.createElement("i"); + bar.style.height = `${Math.max(3, h)}px`; + container.append(bar); + } +} + +export const STAGE_ICONS = { + completed: "✓", + in_progress: "◉", + awaiting_human: "◈", + failed: "✕", +}; diff --git a/backlot/ui/library.js b/backlot/ui/library.js new file mode 100644 index 00000000..5852f866 --- /dev/null +++ b/backlot/ui/library.js @@ -0,0 +1,64 @@ +import { el, fmtAgo, getJSON, subscribe, thumbURL } from "/ui/lib.js"; + +const grid = document.getElementById("grid"); + +function miniRail(states) { + const rail = el("div", { class: "mini-rail" }); + for (const s of states) { + const cls = s.status === "completed" ? "d" + : s.status === "in_progress" ? "a" + : s.status === "awaiting_human" ? "w" : ""; + rail.append(el("i", { class: cls, title: `${s.name}: ${s.status}` })); + } + return rail; +} + +function card(p) { + const poster = el("div", { class: "lib-poster" }); + if (p.poster) { + poster.append(el("img", { src: thumbURL(p.project_id, p.poster, 640), loading: "lazy", alt: "" })); + } else { + poster.append(el("span", { class: "lp-txt" }, "NO MEDIA YET")); + } + if (p.live && p.active_stage) { + poster.append(el("span", { class: "lp-live" }, + el("span", { class: "dot" }), + p.awaiting_human ? "◈ AWAITING YOU" : `LIVE · ${p.active_stage.toUpperCase()}`)); + } else if (p.awaiting_human) { + poster.append(el("span", { class: "lp-live" }, "◈ AWAITING YOU")); + } + + const meta = el("div", { class: "lb-meta" }, + el("span", { class: "chip" }, p.pipeline_type || "unknown"), + p.scene_count ? el("span", { class: "chip" }, `${p.scene_count} scenes`) : null, + p.render_count ? el("span", { class: "chip" }, `${p.render_count} renders`) : null, + el("span", { class: "when" }, fmtAgo(p.last_activity)), + ); + + const staticSuffix = new URLSearchParams(location.search).has("static") ? "?static=1" : ""; + return el("a", { class: `lib-card${p.live ? " live-card" : ""}`, href: `/p/${p.project_id}${staticSuffix}`, style: "text-decoration:none;color:inherit" }, + poster, + el("div", { class: "lib-body" }, + el("h3", {}, (p.title || p.project_id).toUpperCase()), + meta, + p.stage_states.length ? miniRail(p.stage_states) : null, + ), + ); +} + +async function render() { + const projects = await getJSON("/api/projects"); + document.getElementById("count").textContent = `${projects.length} projects`; + const liveCount = projects.filter((p) => p.live).length; + const badge = document.getElementById("liveBadge"); + badge.classList.toggle("idle", liveCount === 0); + document.getElementById("liveText").textContent = liveCount ? `${liveCount} LIVE` : "IDLE"; + grid.innerHTML = ""; + document.getElementById("empty").style.display = projects.length ? "none" : "block"; + for (const p of projects) grid.append(card(p)); +} + +render().catch(console.error); +if (!new URLSearchParams(location.search).has("static")) { + subscribe("/api/library/events", () => render().catch(console.error)); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7b26d94..a983956c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,7 +48,7 @@ OpenMontage/ │ ├── audio/ # TTS (ElevenLabs, OpenAI, Piper), music gen, mixing, enhancement │ ├── avatar/ # Talking head animation, lip sync │ ├── enhancement/ # Upscale, bg removal, face enhance/restore, color grading -│ ├── graphics/ # Image gen (FLUX, DALL-E, Recraft, local diffusion), stock, diagrams, code snippets, math animation +│ ├── graphics/ # Image gen (FLUX, GPT Image, Recraft, local diffusion), stock, diagrams, code snippets, math animation │ ├── publishers/ # (Reserved) │ ├── subtitle/ # SRT/VTT generation from timestamps │ └── video/ # 13 video gen providers, composition, stitching, trimming @@ -383,7 +383,7 @@ All config is validated via Pydantic models in `lib/config_model.py`. | Variable | Used By | Purpose | |----------|---------|---------| | `ELEVENLABS_API_KEY` | elevenlabs_tts, music_gen | TTS, music, sound effects | -| `OPENAI_API_KEY` | openai_tts, openai_image | TTS fallback, DALL-E 3 | +| `OPENAI_API_KEY` | openai_tts, openai_image | TTS fallback, GPT Image 2 | | `XAI_API_KEY` | grok_image, grok_video | Grok image editing/generation, Grok video generation | | `FAL_KEY` | flux_image, kling_video, veo_video, minimax_video, recraft_image | fal.ai hosted models (FLUX, Veo, Kling, MiniMax, Recraft) | | `HEYGEN_API_KEY` | heygen_video | Multi-provider video generation | diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f0460a69..f5e34f7e 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -15,7 +15,7 @@ Everything you need to know about every provider in OpenMontage — setup instru | 3 | **$0** | ElevenLabs | Premium TTS + music + SFX (10K chars/month free) | | 4 | **$0** | Piper (local install) | Fully offline TTS — no API key, no cost, no network | | 5 | **~$0.03/image** | fal.ai | FLUX images + Kling/Veo/MiniMax video + Recraft — broad single-key image + video coverage | -| 6 | **~$0.04/image** | OpenAI | DALL-E 3 images + OpenAI TTS | +| 6 | **~$0.05/image** | OpenAI | GPT Image 2 images + OpenAI TTS | | 7 | **~$0.04/image** | Google Imagen | Imagen 4 images (shares the Google API key) | | 8 | **$12/month** | Runway | Gen-4 video — highest quality AI video | | 9 | **pay-as-you-go** | HeyGen | Avatar videos, multi-model video gateway | @@ -37,10 +37,11 @@ GOOGLE_API_KEY= # Google TTS + Google Imagen # VOICE + MUSIC ELEVENLABS_API_KEY= # TTS, music, sound effects (10K chars/month free) -OPENAI_API_KEY= # OpenAI TTS + DALL-E 3 images +OPENAI_API_KEY= # OpenAI TTS + GPT Image 2 images XAI_API_KEY= # xAI Grok image generation/editing + Grok video generation DOUBAO_SPEECH_API_KEY= # Volcengine Doubao Speech TTS (strong Mandarin narration) DOUBAO_SPEECH_VOICE_TYPE= # Default Doubao speaker/voice type +DASHSCOPE_API_KEY= # Alibaba DashScope (Qwen image gen, TTS, ASR with word timestamps) # MULTI-MODEL GATEWAY (one key, 6+ tools) FAL_KEY= # FLUX, Recraft, Kling, Veo, MiniMax video @@ -94,6 +95,43 @@ OpenMontage now uses those published rates in the Grok tool estimators. --- +### 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. + +**Tools unlocked:** `dashscope_image`, `dashscope_tts`, `dashscope_asr` +**Env var:** `DASHSCOPE_API_KEY` + +#### Setup + +1. Go to [dashscope.aliyun.com](https://dashscope.aliyun.com/) +2. Create an Alibaba Cloud account if you don't have one +3. Generate an API key in the DashScope console +4. Add to `.env`: `DASHSCOPE_API_KEY=sk-...` + +#### What it's best for + +- Chinese-language image generation with strong prompt understanding (Qwen-Image) +- Natural Mandarin narration (Qwen-TTS, Cherry voice) +- Word-level timestamp transcription for subtitle alignment (Qwen-ASR filetrans) +- Replacing the broken `whisperx` slot for ASR + +#### API notes + +DashScope's `/compatible-mode/v1/` only supports `/chat/completions` and `/embeddings`. Image gen, TTS, and ASR all use DashScope-native endpoints with nested `{model, input, parameters}` request shape — not OpenAI-compatible paths. + +The ASR tool (`qwen3-asr-flash-filetrans`) uses an async submit-poll pattern. Audio must be at a publicly accessible URL (local files are not supported). Word timestamps are in milliseconds, normalized to seconds by the tool. + +#### Pricing + +| Model | Price | +|------|-------| +| `qwen-image-2.0-pro` | ~$0.02 per image (check console for current rates) | +| `qwen3-tts-flash` | ~$0.000015 per character | +| `qwen3-asr-flash-filetrans` | Per-minute billing (check console) | + +--- + ### fal.ai — Multi-Model Gateway > **Broad single-key coverage.** One API key unlocks image and video providers across multiple models. @@ -276,7 +314,7 @@ Google TTS offers 700+ voices across 50+ languages. Voice names follow the patte ### OpenAI — TTS + Image Generation -> **Solid all-rounder.** DALL-E 3 handles complex multi-element compositions well. TTS is fast and affordable. +> **Solid all-rounder.** GPT Image 2 handles complex multi-element compositions and in-image text well. TTS is fast and affordable. **Tools unlocked:** `openai_tts`, `openai_image` **Env var:** `OPENAI_API_KEY` @@ -301,10 +339,14 @@ Google TTS offers 700+ voices across 50+ languages. Voice names follow the patte | Model | Size | Quality | Price per image | |-------|------|---------|----------------| -| DALL-E 3 | 1024x1024 | standard | $0.040 | -| DALL-E 3 | 1024x1024 | hd | $0.080 | -| DALL-E 3 | 1024x1792 | standard | $0.080 | -| DALL-E 3 | 1024x1792 | hd | $0.120 | +| GPT Image 2 | 1024x1024 | low | $0.006 | +| GPT Image 2 | 1024x1024 | medium | $0.053 | +| GPT Image 2 | 1024x1024 | high | $0.211 | +| GPT Image 2 | 1024x1536 / 1536x1024 | low | $0.005 | +| GPT Image 2 | 1024x1536 / 1536x1024 | medium | $0.041 | +| GPT Image 2 | 1024x1536 / 1536x1024 | high | $0.165 | + +> **Note:** DALL-E 2/3 were shut down by OpenAI on 2026-05-12, and the `gpt-image-1` family (`gpt-image-1-mini`, `gpt-image-1.5`) retires 2026-12-01 — `gpt-image-2` is OpenAI's recommended replacement ([deprecations](https://developers.openai.com/api/docs/deprecations)). **Free tier:** None. Requires prepaid billing. Previously offered $5 in free credits for new accounts (discontinued for most signups). @@ -672,7 +714,7 @@ First run downloads the model (~4GB). Subsequent runs use the cached model. **VRAM requirement:** 4GB+ (8GB recommended for 1024x1024 images) -**Supports:** Negative prompts, seeds, custom sizes. Quality is lower than FLUX or DALL-E 3 but completely free and offline. +**Supports:** Negative prompts, seeds, custom sizes. Quality is lower than FLUX or GPT Image 2 but completely free and offline. --- @@ -743,7 +785,7 @@ How many providers cover each capability: | Capability | Cloud Providers | Local Providers | Free Options | |-----------|----------------|-----------------|--------------| -| **Image Generation** | FLUX, Grok, Google Imagen, DALL-E 3, Recraft | Local Diffusion | Pexels, Pixabay (stock) | +| **Image Generation** | FLUX, Grok, Google Imagen, GPT Image 2, Recraft | Local Diffusion | Pexels, Pixabay (stock) | | **Video Generation** | Grok, Kling, Runway, Veo, Higgsfield, MiniMax, HeyGen | WAN, Hunyuan, CogVideo, LTX | Pexels, Pixabay (stock) | | **Text-to-Speech** | ElevenLabs, Google TTS, OpenAI | Piper | Piper, Google free tier, ElevenLabs free tier | | **Music Generation** | ElevenLabs, Suno | — | ElevenLabs free tier | diff --git a/docs/images/backlot/board-live.png b/docs/images/backlot/board-live.png new file mode 100644 index 00000000..e905bd87 Binary files /dev/null and b/docs/images/backlot/board-live.png differ diff --git a/docs/images/backlot/library.png b/docs/images/backlot/library.png new file mode 100644 index 00000000..45ce3fb7 Binary files /dev/null and b/docs/images/backlot/library.png differ diff --git a/docs/images/backlot/script-gate.png b/docs/images/backlot/script-gate.png new file mode 100644 index 00000000..53bbdd6e Binary files /dev/null and b/docs/images/backlot/script-gate.png differ diff --git a/docs/images/backlot/storyboard.png b/docs/images/backlot/storyboard.png new file mode 100644 index 00000000..874089b7 Binary files /dev/null and b/docs/images/backlot/storyboard.png differ diff --git a/lib/checkpoint.py b/lib/checkpoint.py index 8a070234..b2b597a0 100644 --- a/lib/checkpoint.py +++ b/lib/checkpoint.py @@ -67,8 +67,8 @@ def get_pipeline_stages(pipeline_type: str | None) -> list[str]: return list(STAGES) try: - from lib.pipeline_loader import load_pipeline, get_stage_order - manifest = load_pipeline(pipeline_type) + from lib.pipeline_loader import load_pipeline_readonly, get_stage_order + manifest = load_pipeline_readonly(pipeline_type) return get_stage_order(manifest) except (FileNotFoundError, Exception): # Graceful fallback: return all known stages in canonical order @@ -81,6 +81,15 @@ CHECKPOINT_SCHEMA_PATH = ( / "checkpoint.schema.json" ) +# Canonical project root. Checkpoints, artifacts, and the project marker all +# live under PROJECTS_DIR// — this is the location the Backlot +# board watches. Callers may still pass a different pipeline_dir (tests do), +# but production runs should use the default. +from lib.paths import PROJECTS_DIR # noqa: E402 (single source of truth) + +PROJECT_MARKER_FILENAME = "project.json" +HISTORY_DIRNAME = "history" + class CheckpointValidationError(ValueError): """Raised when a checkpoint or its canonical artifacts are invalid.""" @@ -157,6 +166,130 @@ def _checkpoint_path(pipeline_dir: Path, project_id: str, stage: str) -> Path: return pipeline_dir / project_id / f"checkpoint_{stage}.json" +def init_project( + project_id: str, + *, + title: str, + pipeline_type: str, + pipeline_dir: Optional[Path] = None, + style_playbook: Optional[str] = None, +) -> Path: + """Initialize a project workspace with the canonical layout + marker file. + + Creates projects// with the standard subdirectories and writes + project.json — the marker the Backlot board uses to render a project's + identity and stage rail before the first checkpoint exists. + + Idempotent: re-running preserves the original created_at and merges fields. + Returns the project directory. + """ + base = pipeline_dir or PROJECTS_DIR + project_dir = base / project_id + for sub in ( + "artifacts", + "assets/images", + "assets/video", + "assets/audio", + "assets/music", + "renders", + ): + (project_dir / sub).mkdir(parents=True, exist_ok=True) + + marker_path = project_dir / PROJECT_MARKER_FILENAME + marker: dict[str, Any] = {} + if marker_path.exists(): + try: + with open(marker_path) as f: + marker = json.load(f) + except (json.JSONDecodeError, OSError): + marker = {} + + marker.setdefault("version", "1.0") + marker.setdefault("created_at", datetime.now(timezone.utc).isoformat()) + marker["project_id"] = project_id + marker["title"] = title + marker["pipeline_type"] = pipeline_type + if style_playbook is not None: + marker["style_playbook"] = style_playbook + + with open(marker_path, "w") as f: + json.dump(marker, f, indent=2) + + return project_dir + + +def _stage_requires_approval(pipeline_type: Optional[str], stage: str) -> Optional[bool]: + """Read human_approval_default for a stage from its pipeline manifest. + + Returns None when the stage isn't declared in the manifest or no + pipeline_type was given — the caller then falls back to the value the + agent passed in. + + A *provided but unknown* pipeline_type raises: a typo must not silently + disable gate enforcement (fail-closed, not fail-open). Other manifest + load failures are logged and fall back — a corrupt manifest shouldn't + strand an otherwise-valid run, but the degradation must be visible. + """ + if not pipeline_type or pipeline_type == "unknown": + return None + from lib.pipeline_loader import get_stage_human_approval_default, load_pipeline_readonly + try: + manifest = load_pipeline_readonly(pipeline_type) + except FileNotFoundError: + raise CheckpointValidationError( + f"Unknown pipeline_type {pipeline_type!r} — cannot resolve gate " + f"policy for stage {stage!r}. Check the spelling against " + f"pipeline_defs/*.yaml." + ) + except Exception as exc: + import logging + logging.getLogger(__name__).warning( + "Gate policy unavailable for pipeline %r (%s) — falling back to " + "the caller's human_approval_required flag.", pipeline_type, exc, + ) + return None + return get_stage_human_approval_default(manifest, stage) + + +def _archive_superseded_checkpoint(path: Path, stage: str) -> None: + """Copy an existing checkpoint into history/ before it is overwritten. + + Preserves the full run record: stage re-runs (script v1 → v2) and gate + transitions (awaiting_human → completed) remain reconstructable. Repeated + in_progress refreshes are NOT archived — they are partial-progress + heartbeats, not versions. + + Archiving is best-effort and must never crash a checkpoint write: the + Backlot watcher may hold the file open (Windows denies renames of open + files), so we copy rather than move, and swallow archival I/O failures. + """ + if not path.exists(): + return + try: + with open(path) as f: + existing = json.load(f) + except (json.JSONDecodeError, OSError): + existing = {} + if existing.get("status") == "in_progress": + return + + try: + import shutil + stamp = str(existing.get("timestamp", "")) + safe_stamp = "".join(c for c in stamp if c.isalnum()) or f"{path.stat().st_mtime_ns}" + history_dir = path.parent / HISTORY_DIRNAME + history_dir.mkdir(parents=True, exist_ok=True) + target = history_dir / f"checkpoint_{stage}_{safe_stamp}.json" + if target.exists(): + target = history_dir / f"checkpoint_{stage}_{safe_stamp}_{path.stat().st_mtime_ns}.json" + shutil.copyfile(path, target) + except OSError: + import logging + logging.getLogger(__name__).warning( + "Could not archive superseded checkpoint %s to history/", path + ) + + def _decision_log_path(pipeline_dir: Path, project_id: str) -> Path: return pipeline_dir / project_id / "decision_log.json" @@ -209,6 +342,20 @@ 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"): + pipeline_type = marker["pipeline_type"] + valid_stages = ( set(get_pipeline_stages(pipeline_type)) if pipeline_type else ALL_KNOWN_STAGES @@ -219,6 +366,35 @@ def write_checkpoint( f"Valid stages: {sorted(valid_stages)}" ) + # --- Gate enforcement (GI-4) --- + # The pipeline manifest is the binding source of truth for whether a stage + # gates on human approval; a caller may gate MORE strictly (e.g. a + # manual_all checkpoint policy) but never less. A gated stage can only be + # written "completed" with explicit evidence of approval + # (human_approved=True). Skipping a gate is a hard error. + # + # Enforcement happens at write time only: pre-existing checkpoints written + # before gating (or by hand) still read as completed — deliberate + # back-compat so in-flight and legacy projects keep resuming. + manifest_gate = _stage_requires_approval(pipeline_type, stage) + gated = bool(manifest_gate) or human_approval_required + if gated: + human_approval_required = True + if status == "completed" and not human_approved: + gate_source = ( + f"human_approval_default: true in the {pipeline_type!r} manifest" + if manifest_gate + else "human_approval_required=True was passed by the caller" + ) + raise CheckpointValidationError( + f"GATE VIOLATION: stage {stage!r} requires human approval " + f"({gate_source}) but status='completed' was written without " + f"human_approved=True. Correct protocol: write " + f"status='awaiting_human', present the artifact summary to the " + f"user, END YOUR TURN, and only after the user approves " + f"re-write with status='completed', human_approved=True." + ) + checkpoint = { "version": "1.0", "project_id": project_id, @@ -266,8 +442,18 @@ def write_checkpoint( path = _checkpoint_path(pipeline_dir, project_id, stage) path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: + # Serialize to a temp file first so a mid-write failure (disk full, + # unserializable metadata) can never leave the stage with a truncated + # 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: 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). + _archive_superseded_checkpoint(path, stage) + import os + os.replace(tmp_path, path) return path diff --git a/lib/delivery_promise.py b/lib/delivery_promise.py index 9d99b96e..9f3bad39 100644 --- a/lib/delivery_promise.py +++ b/lib/delivery_promise.py @@ -227,14 +227,14 @@ def classify_from_brief( if user_intent.get("motion_required") is False and promise_type == PromiseType.MOTION_LED: promise_type = PromiseType.HYBRID - motion_required = user_intent.get("motion_required", promise_type in ( - PromiseType.MOTION_LED, PromiseType.AVATAR_PRESENTER, - )) - source_required = user_intent.get("has_footage", False) if source_required and promise_type not in (PromiseType.SOURCE_LED, PromiseType.LOCALIZATION): promise_type = PromiseType.SOURCE_LED + motion_required = user_intent.get("motion_required", promise_type in ( + PromiseType.MOTION_LED, PromiseType.AVATAR_PRESENTER, + )) + tone_mode = user_intent.get("tone", "corporate") quality_floor = user_intent.get("quality", "presentable") diff --git a/lib/events.py b/lib/events.py new file mode 100644 index 00000000..a552b861 --- /dev/null +++ b/lib/events.py @@ -0,0 +1,118 @@ +"""Backlot event stream — append-only tool-event log per project. + +Written by the BaseTool instrumentation layer (tools/base_tool.py) whenever a +tool executes against a project directory; consumed by the Backlot board's +watcher to power live activity and per-scene generating states. + +Design rules: +- Observability must never break production: every public function swallows + its own errors. A failed event write is silently dropped. +- Zero agent burden: project attribution is inferred from the tool's inputs + (explicit ``project_dir`` or any path argument under ``projects/``). +""" + +from __future__ import annotations + +import json +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from lib.paths import PROJECTS_DIR, REPO_ROOT # single source of truth + +EVENTS_FILENAME = "events.jsonl" + +# Thread-level serialization only. Cross-PROCESS appends are unsynchronized +# by design: single-line O_APPEND writes rarely tear, and read_events skips +# malformed lines, so a torn line degrades to one missing activity entry. +_write_lock = threading.Lock() + +# Input keys checked (in order) when inferring the project a tool call +# belongs to. Explicit project keys win over path inference. +_EXPLICIT_PROJECT_KEYS = ("project_dir", "project_path") +_PATH_HINT_KEYS = ( + "output_path", + "output_dir", + "output_file", + "input_path", + "video_path", + "audio_path", + "image_path", + "file_path", +) + + +def infer_project_dir(inputs: Any) -> Optional[Path]: + """Best-effort: which project directory does this tool call belong to? + + Returns None when the call can't be attributed — the event is then + simply not emitted (principle: never guess loudly, never fail). + """ + if not isinstance(inputs, dict): + return None + try: + # Only paths under the canonical projects root are attributable — + # an explicit project_dir pointing elsewhere (HyperFrames workspace, + # arbitrary user dir) must not receive an events.jsonl. Explicit + # values are normalized to the project ROOT the same way hints are, + # so project_dir="projects/x/renders/build" attributes to projects/x. + projects_root = PROJECTS_DIR.resolve() + for key in _EXPLICIT_PROJECT_KEYS + _PATH_HINT_KEYS: + value = inputs.get(key) + if not isinstance(value, (str, Path)) or not str(value): + continue + try: + resolved = Path(value).resolve() + rel = resolved.relative_to(projects_root) + except (ValueError, OSError): + continue + if rel.parts: + return PROJECTS_DIR / rel.parts[0] + except Exception: + return None + return None + + +def emit_event(project_dir: Path | str, payload: dict[str, Any]) -> None: + """Append one event to the project's events.jsonl. Never raises. + + Writes only into an EXISTING project directory — a typo'd path must not + spawn a ghost project on the board. + """ + try: + project_dir = Path(project_dir) + if not project_dir.is_dir(): + return + entry = {"ts": datetime.now(timezone.utc).isoformat()} + entry.update({k: v for k, v in payload.items() if v is not None}) + path = project_dir / EVENTS_FILENAME + line = json.dumps(entry, default=str) + with _write_lock: + with open(path, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + + +def read_events(project_dir: Path | str, limit: Optional[int] = None) -> list[dict[str, Any]]: + """Read events for a project (oldest first). Tolerates malformed lines.""" + path = Path(project_dir) / EVENTS_FILENAME + if not path.exists(): + return [] + events: list[dict[str, Any]] = [] + try: + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + except OSError: + return [] + if limit is not None: + return events[-limit:] + return events diff --git a/lib/paths.py b/lib/paths.py new file mode 100644 index 00000000..37149fb3 --- /dev/null +++ b/lib/paths.py @@ -0,0 +1,17 @@ +"""Canonical repository paths — single source of truth. + +The projects root is the most load-bearing path in the system: checkpoints +are written under it, tool events are attributed against it, and the Backlot +board watches it. Define it once. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Overridable for staging/screenshots/tests. Everything — checkpoint writes, +# event attribution, the Backlot board — follows the same root. +PROJECTS_DIR = Path(os.environ.get("OPENMONTAGE_PROJECTS_DIR") or (REPO_ROOT / "projects")) diff --git a/lib/pipeline_loader.py b/lib/pipeline_loader.py index 727f6791..6ced59c7 100644 --- a/lib/pipeline_loader.py +++ b/lib/pipeline_loader.py @@ -21,11 +21,31 @@ SCHEMA_PATH = ( ) +from functools import lru_cache + + +@lru_cache(maxsize=1) def _load_manifest_schema() -> dict: with open(SCHEMA_PATH) as f: return json.load(f) +@lru_cache(maxsize=64) +def _load_pipeline_cached(name: str, defs_dir_key: str) -> dict[str, Any]: + """Cached manifest load. Treat the returned dict as READ-ONLY.""" + return load_pipeline(name, Path(defs_dir_key) if defs_dir_key else None) + + +def load_pipeline_readonly(name: str, defs_dir: Optional[Path] = None) -> dict[str, Any]: + """Load a manifest through a cache. The result MUST NOT be mutated. + + Manifests are immutable within a run; hot paths (gate checks on every + checkpoint write, board state derivation) should use this instead of + re-parsing YAML + re-validating the schema each call. + """ + return _load_pipeline_cached(name, str(defs_dir) if defs_dir else "") + + def load_pipeline(name: str, defs_dir: Optional[Path] = None) -> dict[str, Any]: """Load and validate a pipeline manifest by name. @@ -150,6 +170,18 @@ def get_stage_skill(manifest: dict, stage_name: str) -> Optional[str]: return None +def get_stage_human_approval_default(manifest: dict, stage_name: str) -> Optional[bool]: + """Whether a stage gates on human approval. None if the stage isn't declared. + + This is the single lookup used by gate enforcement (lib/checkpoint.py) + and the Backlot board — keep them reading the same field the same way. + """ + for stage in manifest["stages"]: + if stage["name"] == stage_name: + return bool(stage.get("human_approval_default", False)) + return None + + def get_stage_review_focus(manifest: dict, stage_name: str) -> list[str]: """Get the review focus items for a stage.""" for stage in manifest["stages"]: diff --git a/lib/scoring.py b/lib/scoring.py index 618c6bb2..e1a8759b 100644 --- a/lib/scoring.py +++ b/lib/scoring.py @@ -147,7 +147,7 @@ _SYNONYM_CLUSTERS: list[set[str]] = [ {"music", "soundtrack", "background-music", "score", "ambient"}, ] -_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9+._-]*") +_TOKEN_RE = re.compile(r"[a-z0-9](?:[a-z0-9+._-]*[a-z0-9])?") _GENERATED_VISUAL_TERMS = { "animated", "animation", @@ -498,7 +498,7 @@ def score_provider(tool, task_context: dict[str, Any]) -> ProviderScore: # lip-sync from quoted dialogue. This is what makes Seedance 2.0 (and # peer premium APIs) meaningfully better than generic clip providers. if asset_type == "video": - intent_words = _expand_synonyms(set(intent.lower().split())) | set(style_keywords) + intent_words = _expand_synonyms(set(_tokenize_text(intent))) | set(style_keywords) cinematic_signal = bool( intent_words & {"cinematic", "film", "movie", "trailer", "teaser", "dramatic", "epic", "premium"} ) diff --git a/lib/variation_checker.py b/lib/variation_checker.py index 2604a941..47829f10 100644 --- a/lib/variation_checker.py +++ b/lib/variation_checker.py @@ -56,13 +56,20 @@ def check_scene_variation(scenes: list[dict[str, Any]]) -> dict[str, Any]: suggestions.append("Mix wide establishing shots with close-ups for visual rhythm.") # --- Check 2: Consecutive same-size shots --- - consecutive_same = 0 + # Track the longest actual run of identical shot sizes. Summing every equal + # adjacent pair across the whole plan would count non-consecutive groups + # (e.g. wide,wide,cu,cu,med,med -> 3 pairs) as a single "3 consecutive" run. + longest_run = 1 if shot_sizes else 0 + current_run = 1 for i in range(1, len(shot_sizes)): if shot_sizes[i] == shot_sizes[i-1] and shot_sizes[i] != "unspecified": - consecutive_same += 1 - if consecutive_same >= 3: + current_run += 1 + longest_run = max(longest_run, current_run) + else: + current_run = 1 + if longest_run >= 3: violations.append( - f"{consecutive_same} consecutive same-size shots. " + f"{longest_run} consecutive same-size shots. " f"Vary shot sizes between scenes for editorial rhythm." ) diff --git a/pipeline_defs/animated-explainer.yaml b/pipeline_defs/animated-explainer.yaml index fb6c9f84..bd17a197 100644 --- a/pipeline_defs/animated-explainer.yaml +++ b/pipeline_defs/animated-explainer.yaml @@ -184,7 +184,7 @@ stages: - music_gen - math_animate checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - All asset files exist on disk - Narration covers all script sections @@ -257,7 +257,7 @@ stages: - proposal_packet produces: - publish_log - tools_available: [] + tools_available: [export_bundle] checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/animation.yaml b/pipeline_defs/animation.yaml index e2361cc3..1002eabf 100644 --- a/pipeline_defs/animation.yaml +++ b/pipeline_defs/animation.yaml @@ -193,7 +193,7 @@ stages: - code_snippet - music_gen checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Asset production path is explicit per scene - Reusable motifs and templates are prepared and referenced diff --git a/pipeline_defs/avatar-spokesperson.yaml b/pipeline_defs/avatar-spokesperson.yaml index 4b6afae7..21beedf3 100644 --- a/pipeline_defs/avatar-spokesperson.yaml +++ b/pipeline_defs/avatar-spokesperson.yaml @@ -124,7 +124,7 @@ stages: - audio_enhance - video_selector checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Avatar generation path is explicit and honest (including no-avatar pivot if applicable) - Narration, subtitle, and background assets are aligned diff --git a/pipeline_defs/character-animation.yaml b/pipeline_defs/character-animation.yaml index 22213467..e40452ec 100644 --- a/pipeline_defs/character-animation.yaml +++ b/pipeline_defs/character-animation.yaml @@ -212,7 +212,7 @@ stages: - music_gen - character_rig_renderer checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Character parts, backgrounds, props, audio, and effects are linked to scenes - Layer 3 skills are read for every generation or animation-runtime tool diff --git a/pipeline_defs/cinematic.yaml b/pipeline_defs/cinematic.yaml index 1c089f92..0f806d6b 100644 --- a/pipeline_defs/cinematic.yaml +++ b/pipeline_defs/cinematic.yaml @@ -183,7 +183,7 @@ stages: - freesound_music - music_gen checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Source selects and support assets are clearly separated - Motion-required beats use actual video clips rather than still-image substitutes diff --git a/pipeline_defs/clip-factory.yaml b/pipeline_defs/clip-factory.yaml index 690957f2..8f4e1345 100644 --- a/pipeline_defs/clip-factory.yaml +++ b/pipeline_defs/clip-factory.yaml @@ -123,7 +123,7 @@ stages: - subtitle_gen - audio_enhance checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Per-clip subtitles generated with correct time offsets - Shared title / hook / branding assets prepared for each clip diff --git a/pipeline_defs/documentary-montage.yaml b/pipeline_defs/documentary-montage.yaml index d1bb3645..2b7ef65c 100644 --- a/pipeline_defs/documentary-montage.yaml +++ b/pipeline_defs/documentary-montage.yaml @@ -102,7 +102,7 @@ stages: - clip_search - music_gen checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Every slot has exactly one picked clip - No clip_id is picked for two slots diff --git a/pipeline_defs/hybrid.yaml b/pipeline_defs/hybrid.yaml index 0de387eb..b46dd642 100644 --- a/pipeline_defs/hybrid.yaml +++ b/pipeline_defs/hybrid.yaml @@ -138,7 +138,7 @@ stages: - music_gen - audio_enhance checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Support assets clearly map to real narrative gaps - Shared template assets are reused diff --git a/pipeline_defs/localization-dub.yaml b/pipeline_defs/localization-dub.yaml index 726a348d..f4ee1fb5 100644 --- a/pipeline_defs/localization-dub.yaml +++ b/pipeline_defs/localization-dub.yaml @@ -125,7 +125,7 @@ stages: - lip_sync - audio_enhance checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Subtitle and dubbed-audio assets exist for each language - Timing and pronunciation risks are recorded diff --git a/pipeline_defs/podcast-repurpose.yaml b/pipeline_defs/podcast-repurpose.yaml index f41e97f9..ed33181a 100644 --- a/pipeline_defs/podcast-repurpose.yaml +++ b/pipeline_defs/podcast-repurpose.yaml @@ -131,7 +131,7 @@ stages: - music_gen - audio_enhance checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Subtitles generated for all clips and full episode - Quote card / speaker-card assets match playbook style diff --git a/pipeline_defs/screen-demo.yaml b/pipeline_defs/screen-demo.yaml index 950f4845..9b7920d7 100644 --- a/pipeline_defs/screen-demo.yaml +++ b/pipeline_defs/screen-demo.yaml @@ -162,7 +162,7 @@ stages: - diagram_gen - audio_enhance checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Subtitle file exists and matches speech timing - Reusable callout overlays (arrows, highlights, masks) are prepared diff --git a/pipeline_defs/talking-head.yaml b/pipeline_defs/talking-head.yaml index 33a21787..8bcfd35e 100644 --- a/pipeline_defs/talking-head.yaml +++ b/pipeline_defs/talking-head.yaml @@ -124,7 +124,7 @@ stages: - audio_mixer - image_selector checkpoint_required: true - human_approval_default: false + human_approval_default: true review_focus: - Subtitle file exists and matches transcript timing - Audio extracted and normalized diff --git a/remotion-composer/package-lock.json b/remotion-composer/package-lock.json index 78a51c14..4b527227 100644 --- a/remotion-composer/package-lock.json +++ b/remotion-composer/package-lock.json @@ -17,7 +17,9 @@ "d3-geo": "^3.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "remotion": "^4.0.484" + "remotion": "^4.0.484", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" }, "devDependencies": { "@types/react": "^18.2.0", @@ -2832,6 +2834,20 @@ "node": ">=4" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -3004,6 +3020,12 @@ "node": ">= 8" } }, + "node_modules/world-atlas": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", + "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/remotion-composer/package.json b/remotion-composer/package.json index dac550f9..2e5bfad9 100644 --- a/remotion-composer/package.json +++ b/remotion-composer/package.json @@ -17,7 +17,9 @@ "d3-geo": "^3.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "remotion": "^4.0.484" + "remotion": "^4.0.484", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" }, "devDependencies": { "@types/react": "^18.2.0", diff --git a/requirements-dev.txt b/requirements-dev.txt index 86b44963..97a29df5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ -r requirements.txt pytest>=8.0 pytest-asyncio>=0.23 +httpx2>=2.0 diff --git a/requirements.txt b/requirements.txt index e235e4de..60d2884c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,8 @@ numpy>=1.24 requests>=2.31 google-auth>=2.0 # service-account auth for Google TTS + Imagen (Vertex AI) openai>=2.44.0 # Videos API support for Sora 2 + +# Backlot - the living storyboard (local board server) +fastapi>=0.110 +uvicorn>=0.29 +watchfiles>=0.21 diff --git a/schemas/artifacts/proposal_packet.schema.json b/schemas/artifacts/proposal_packet.schema.json index c6af6308..1f47f24e 100644 --- a/schemas/artifacts/proposal_packet.schema.json +++ b/schemas/artifacts/proposal_packet.schema.json @@ -164,6 +164,7 @@ "type": "string", "description": "Required when composition_mode='atelier'. Short note (or path to art-direction.md) committing to a fresh visual language for THIS piece — palette, type, motion, signature device. Per skills/meta/bespoke-composition.md step 1, written down BEFORE authoring scenes." }, + "taste_profile": { "$ref": "#/$defs/taste_profile" }, "music_source": { "type": "object", "description": "Resolved music plan from the proposal stage", @@ -331,5 +332,47 @@ }, "metadata": { "type": "object" } }, + "$defs": { + "taste_profile": { + "type": "object", + "required": ["design_read", "visual_variance", "motion_intensity", "information_density"], + "properties": { + "design_read": { + "type": "string", + "description": "Brief-specific read of what the video should feel like and why." + }, + "visual_variance": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How much scenes may vary visually while still feeling coherent." + }, + "motion_intensity": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How energetic the motion language should be." + }, + "information_density": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How much information can be on screen at once." + }, + "palette_discipline": { "type": "string" }, + "layout_variation": { "type": "string" }, + "reference_strategy": { "type": "string" }, + "anti_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "quality_gates": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + } + }, "additionalProperties": false } diff --git a/schemas/artifacts/source_media_review.schema.json b/schemas/artifacts/source_media_review.schema.json index 7e603502..0e89cdc8 100644 --- a/schemas/artifacts/source_media_review.schema.json +++ b/schemas/artifacts/source_media_review.schema.json @@ -9,6 +9,7 @@ "version": { "type": "string", "const": "1.0" }, "files": { "type": "array", + "description": "Reviewed source files. Empty when no user media was supplied (or none could be reviewed) — a valid 'fully generated production' state that review_source_media reports explicitly.", "items": { "type": "object", "required": ["path", "media_type", "reviewed"], @@ -64,7 +65,7 @@ }, "additionalProperties": false }, - "minItems": 1 + "minItems": 0 }, "summary": { "type": "string", diff --git a/schemas/styles/playbook.schema.json b/schemas/styles/playbook.schema.json index a8c22c21..7f6aa27c 100644 --- a/schemas/styles/playbook.schema.json +++ b/schemas/styles/playbook.schema.json @@ -159,6 +159,7 @@ "items": { "type": "string" }, "minItems": 1 }, + "taste_profile": { "$ref": "#/$defs/taste_profile" }, "chart_palette": { "description": "Ordered array of hex colors for chart data series.", "type": "array", @@ -238,6 +239,46 @@ "highlight": { "type": "string" } }, "additionalProperties": false + }, + "taste_profile": { + "type": "object", + "required": ["design_read", "visual_variance", "motion_intensity", "information_density"], + "properties": { + "design_read": { + "type": "string", + "description": "Brief-specific read of what the video should feel like and why." + }, + "visual_variance": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How much scenes may vary visually while still feeling coherent." + }, + "motion_intensity": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How energetic the motion language should be." + }, + "information_density": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "How much information can be on screen at once." + }, + "palette_discipline": { "type": "string" }, + "layout_variation": { "type": "string" }, + "reference_strategy": { "type": "string" }, + "anti_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "quality_gates": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false } } } diff --git a/scripts/atelier_snapshots.py b/scripts/atelier_snapshots.py new file mode 100644 index 00000000..fc1e1867 --- /dev/null +++ b/scripts/atelier_snapshots.py @@ -0,0 +1,121 @@ +"""Render one review still per scene for an atelier (bespoke) composition. + +The Backlot storyboard can't thumbnail a `.tsx` scene, so a bespoke run +populates the assets-gate filmstrip by writing `projects//snapshots/ +.png` — one Remotion `still` per scene at a representative frame. +Run this AT THE ASSETS GATE (before any draft/compose render): + + python scripts/atelier_snapshots.py + +It reads scene timings from `artifacts/scene_plan.json` and the bespoke render +config from `artifacts/edit_decisions.json` (falling back to conventional +paths: index.tsx / artifacts/props.json / public/). The composition id comes +from edit_decisions.bespoke.composition_id or --composition-id. + +See skills/meta/bespoke-composition.md and skills/meta/checkpoint-protocol.md. +""" +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + +# On Windows npx is npx.cmd — resolve it so subprocess finds it without a shell. +NPX = shutil.which("npx") or "npx" + +REPO_ROOT = Path(__file__).resolve().parent.parent +COMPOSER_DIR = REPO_ROOT / "remotion-composer" + + +def _load(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("slug", help="project slug under projects/") + ap.add_argument("--composition-id", help="Remotion composition id (else from edit_decisions)") + ap.add_argument("--entry", help="entry .tsx (default projects//index.tsx)") + ap.add_argument("--props", help="props JSON (default artifacts/props.json)") + ap.add_argument("--public-dir", help="public dir (default projects//public)") + ap.add_argument("--fps", type=int, default=None, help="frames per second (default from props or 30)") + ap.add_argument("--only", nargs="*", help="only these scene ids") + args = ap.parse_args(argv) + + proj = REPO_ROOT / "projects" / args.slug + if not proj.is_dir(): + print(f"error: no project at {proj}", file=sys.stderr) + return 2 + + scene_plan = _load(proj / "artifacts" / "scene_plan.json") + scenes = (scene_plan.get("scenes") or []) if isinstance(scene_plan, dict) else [] + if not scenes: + print("error: no scenes in artifacts/scene_plan.json", file=sys.stderr) + return 2 + + edit = _load(proj / "artifacts" / "edit_decisions.json") + bespoke = (edit.get("bespoke") or {}) if isinstance(edit, dict) else {} + props_path = Path(args.props or bespoke.get("props_path") or (proj / "artifacts" / "props.json")) + entry = Path(args.entry or bespoke.get("entry") or (proj / "index.tsx")) + if not entry.is_absolute(): + entry = (REPO_ROOT / entry).resolve() + public_dir = Path(args.public_dir or bespoke.get("public_dir") or (proj / "public")) + comp_id = args.composition_id or bespoke.get("composition_id") + if not comp_id: + print("error: composition id unknown (pass --composition-id or set edit_decisions.bespoke)", file=sys.stderr) + return 2 + + fps = args.fps + if fps is None: + props = _load(props_path) + fps = int(props.get("fps") or 30) + + # Stage the project into remotion-composer so webpack resolves node_modules. + sys.path.insert(0, str(REPO_ROOT)) + from tools.video.video_compose import VideoCompose # noqa: E402 + staged_entry = VideoCompose()._stage_atelier_project(entry, COMPOSER_DIR) + + snap_dir = proj / "snapshots" + snap_dir.mkdir(exist_ok=True) + + ok, fail = 0, 0 + for sc in scenes: + sid = str(sc.get("id") or "").strip() + if not sid: + continue + if args.only and sid not in args.only: + continue + start = sc.get("start_seconds") + end = sc.get("end_seconds") + mid = ((start + end) / 2) if (start is not None and end is not None) else (start or 0) + frame = max(0, round(mid * fps)) + out = snap_dir / f"{sid}.png" + cmd = [ + NPX, "remotion", "still", str(staged_entry), str(comp_id), str(out.resolve()), + f"--frame={frame}", + f"--props={props_path.resolve()}", + f"--public-dir={public_dir.resolve()}", + ] + try: + subprocess.run(cmd, cwd=COMPOSER_DIR, check=True, capture_output=True, text=True, timeout=600) + ok += 1 + print(f" {sid}: frame {frame} -> {out.relative_to(REPO_ROOT)}") + except subprocess.CalledProcessError as e: + fail += 1 + print(f" {sid}: FAILED — {(e.stderr or e.stdout or '')[-300:]}", file=sys.stderr) + except Exception as e: # noqa: BLE001 + fail += 1 + print(f" {sid}: FAILED — {e}", file=sys.stderr) + + print(f"snapshots: {ok} ok, {fail} failed -> {snap_dir.relative_to(REPO_ROOT)}") + return 0 if fail == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backlot_screenshot_stage.py b/scripts/backlot_screenshot_stage.py new file mode 100644 index 00000000..0230a6f6 --- /dev/null +++ b/scripts/backlot_screenshot_stage.py @@ -0,0 +1,358 @@ +"""Stage demo productions + capture the README screenshots for Backlot. + +Builds a handful of fictional projects (generated cinematic placeholder art — +safe for the public repo, no real project content) into a staging projects +dir, serves Backlot against it via OPENMONTAGE_PROJECTS_DIR, and captures +screenshots with Playwright. + + python scripts/backlot_screenshot_stage.py # stage + shoot + python scripts/backlot_screenshot_stage.py --stage-only +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import shutil +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +STAGE_DIR = REPO_ROOT / ".backlot" / "screenshot-stage" +SHOTS_DIR = REPO_ROOT / "docs" / "images" / "backlot" +PORT = 4790 + +os.environ["OPENMONTAGE_PROJECTS_DIR"] = str(STAGE_DIR) +sys.path.insert(0, str(REPO_ROOT)) + +from PIL import Image, ImageDraw, ImageFilter # noqa: E402 + +from lib.checkpoint import init_project, write_checkpoint # noqa: E402 +from lib.events import emit_event # noqa: E402 +from tests.contracts.test_phase0_contracts import sample_artifact # noqa: E402 + + +# --------------------------------------------------------------------------- +# generated cinematic frames +# --------------------------------------------------------------------------- + +def cinematic_frame(path: Path, top, bottom, glow, seed: int, label: str = "") -> None: + """A moody gradient plate: sky gradient, horizon glow, vignette, grain.""" + w, h = 960, 540 + img = Image.new("RGB", (w, h)) + px = img.load() + for y in range(h): + t = y / h + r = int(top[0] + (bottom[0] - top[0]) * t) + g = int(top[1] + (bottom[1] - top[1]) * t) + b = int(top[2] + (bottom[2] - top[2]) * t) + for x in range(w): + px[x, y] = (r, g, b) + + # horizon glow + light disc (screen blend so it actually GLOWS) + from PIL import ImageChops + glow_layer = Image.new("RGB", (w, h), (0, 0, 0)) + gd = ImageDraw.Draw(glow_layer) + cx, cy = w // 2 + (seed % 200 - 100), int(h * 0.62) + for radius, alpha in ((380, 70), (240, 120), (140, 180), (70, 255)): + gd.ellipse([cx - radius, cy - radius // 2, cx + radius, cy + radius // 2], + fill=tuple(int(c * alpha / 255) for c in glow)) + gd.ellipse([cx - 34, cy - 90, cx + 34, cy - 22], + fill=tuple(min(255, int(c * 1.15)) for c in glow)) + glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(36)) + img = ImageChops.screen(img, glow_layer) + + d = ImageDraw.Draw(img) + # horizon line + silhouette blocks + d.line([(0, cy + 40), (w, cy + 40)], fill=tuple(int(c * 0.25) for c in glow), width=2) + rnd = seed + for i in range(6): + rnd = (rnd * 16807) % 2147483647 + bx = (rnd % w) + bw = 30 + rnd % 90 + bh = 20 + rnd % 70 + d.rectangle([bx, cy + 40 - bh, bx + bw, cy + 40], fill=(6, 7, 9)) + # grain + rnd = seed + 7 + for _ in range(2600): + rnd = (rnd * 48271) % 2147483647 + x, y = rnd % w, (rnd // w) % h + v = px[x, y] + px[x, y] = tuple(min(255, c + 10) for c in v) + # vignette + vin = Image.new("L", (w, h), 0) + vd = ImageDraw.Draw(vin) + vd.ellipse([-w * 0.25, -h * 0.35, w * 1.25, h * 1.35], fill=255) + vin = vin.filter(ImageFilter.GaussianBlur(120)) + img = Image.composite(img, Image.new("RGB", (w, h), (0, 0, 0)), vin) + if label: + d = ImageDraw.Draw(img) + d.text((28, h - 46), label.upper(), fill=(210, 205, 195)) + path.parent.mkdir(parents=True, exist_ok=True) + img.save(path) + + +PALETTES = { + "lighthouse": (((8, 12, 24), (28, 22, 16), (240, 168, 60))), + "static": (((14, 8, 28), (10, 16, 40), (120, 140, 255))), + "orchard": (((6, 18, 14), (20, 30, 18), (140, 220, 140))), + "paper": (((30, 24, 18), (16, 12, 10), (235, 200, 150))), +} + + +# --------------------------------------------------------------------------- +# project staging +# --------------------------------------------------------------------------- + +def script_artifact(title: str, scenes: list) -> dict: + return { + "version": "1.0", "title": title, + "total_duration_seconds": scenes[-1][3], + "sections": [ + {"id": f"s{i+1}", "label": desc.split("—")[0].strip()[:40], "text": narr, + "start_seconds": s0, "end_seconds": s1} + for i, (sid, desc, s0, s1, narr) in enumerate(scenes) + ], + } + + +def scene_plan_artifact(scenes: list, hero: str) -> dict: + return { + "version": "1.0", + "scenes": [ + {"id": sid, "type": "generated", "description": desc, + "start_seconds": s0, "end_seconds": s1, "script_section_id": f"s{i+1}", + "hero_moment": sid == hero, + "shot_language": {"shot_size": ["wide", "medium", "close_up", "extreme_close_up"][i % 4], + "camera_movement": ["static", "dolly_in", "pan_right", "orbital"][i % 4], + "lens_mm": [24, 50, 85, 35][i % 4], + "lighting_key": ["golden_hour", "low_key", "rim_lit", "natural"][i % 4]}, + "required_assets": [{"type": "image", "description": desc, "source": "generate"}]} + for i, (sid, desc, s0, s1, _narr) in enumerate(scenes) + ], + } + + +def decision_log(pid: str) -> dict: + return { + "version": "1.0", "project_id": pid, + "decisions": [ + {"decision_id": "d-001", "stage": "proposal", "category": "provider_selection", + "subject": "image generation", + "options_considered": [ + {"option_id": "flux_image", "label": "FLUX", "score": 0.9, + "reason": "strongest cinematic realism at 16:9"}, + {"option_id": "openai_image", "label": "gpt-image-1", "score": 0.7, + "reason": "solid, slightly flatter light", + "rejected_because": "less atmospheric depth for night scenes"}], + "selected": "flux_image", + "reason": "Strongest cinematic realism for night exteriors.", + "user_visible": True, "user_approved": True, "confidence": 0.9}, + {"decision_id": "d-002", "stage": "proposal", "category": "render_runtime_selection", + "subject": "compose", + "options_considered": [ + {"option_id": "remotion", "label": "Remotion", "score": 0.85, + "reason": "spring typography for the title cards"}, + {"option_id": "hyperframes", "label": "HyperFrames", "score": 0.6, + "reason": "GSAP-native motion", "rejected_because": "stock React stack fits better"}], + "selected": "remotion", "reason": "Native title cards with spring physics.", + "user_visible": True, "user_approved": True, "confidence": 0.85}, + ], + } + + +def stage_project(pid: str, title: str, palette: str, scenes: list, *, + state: str, hero: str, takes_scene: str | None = None) -> None: + """state: 'complete' | 'assets_live' | 'script_gate' | 'early'""" + top, bottom, glow = PALETTES[palette] + pdir = STAGE_DIR / pid + init_project(pid, title=title, pipeline_type="cinematic", + pipeline_dir=STAGE_DIR, style_playbook="clean-professional") + art_dir = pdir / "artifacts" + + def cp(stage, status, artifacts, **kw): + write_checkpoint(STAGE_DIR, pid, stage, status, artifacts, + pipeline_type="cinematic", **kw) + time.sleep(0.02) # distinct mtimes/timestamps + + brief = sample_artifact("research_brief") + brief["topic"] = title + cp("research", "completed", {"research_brief": brief}) + + 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)) + + if state == "early": + cp("script", "in_progress", {}) + return + + (art_dir / "script.json").write_text(json.dumps(script, indent=2)) + if state == "script_gate": + cp("script", "awaiting_human", {"script": script}, + review={"round": 1, "decision": "pass", "critical": 0, + "suggestions": 2, "nitpicks": 1, + "summary": "Hook rewritten to a direct claim; s3 tightened."}) + return + + cp("script", "awaiting_human", {"script": script}, + review={"round": 1, "decision": "pass", "critical": 0, "suggestions": 1, + "nitpicks": 0, "summary": "Strong spine; trimmed s2."}) + cp("script", "completed", {"script": script}, human_approved=True) + (art_dir / "scene_plan.json").write_text(json.dumps(plan, indent=2)) + cp("scene_plan", "awaiting_human", {"scene_plan": plan}) + cp("scene_plan", "completed", {"scene_plan": plan}, human_approved=True) + + # assets + cp("assets", "in_progress", {}) + manifest = {"version": "1.0", "assets": [], "total_cost_usd": 0.0} + n_done = len(scenes) if state == "complete" else max(1, len(scenes) - 2) + for i, (sid, desc, _s0, _s1, _n) in enumerate(scenes[:n_done]): + emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": sid}) + rel = f"assets/images/{sid}.png" + n_takes = 3 if sid == takes_scene else 1 + for take in range(n_takes): + take_rel = rel if take == n_takes - 1 else f"assets/images/{sid}_t{take+1}.png" + cinematic_frame(pdir / take_rel, top, bottom, glow, + seed=i * 97 + take * 31 + 11, label=f"{title} · {sid}") + manifest["assets"].append({ + "id": f"img_{sid}_{take+1}", "type": "image", "path": take_rel, + "scene_id": sid, "source_tool": "flux_image", "model": "flux-1.1-pro", + "cost_usd": 0.04, "prompt": desc, + "quality_score": round(0.84 + take * 0.04, 2)}) + manifest["total_cost_usd"] = round(manifest["total_cost_usd"] + 0.04, 2) + emit_event(pdir, {"tool": "flux_image", "event": "finish", "scene_id": sid, + "success": True, "cost_usd": 0.04 * n_takes, "duration_s": 18.4, + "output_path": rel}) + (art_dir / "asset_manifest.json").write_text(json.dumps(manifest, indent=2)) + write_checkpoint(STAGE_DIR, pid, "assets", "in_progress", {}, + pipeline_type="cinematic", + metadata={"partial_progress": { + "completed_scene_ids": [s[0] for s in scenes[:i + 1]]}}, + cost_snapshot={"total_spent_usd": manifest["total_cost_usd"], + "total_reserved_usd": 0.0, + "budget_remaining_usd": round(4 - manifest["total_cost_usd"], 2)}) + + if state == "assets_live": + # one scene actively generating right now + gen_sid = scenes[n_done][0] + emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": gen_sid}) + return + + cp("assets", "awaiting_human", {"asset_manifest": manifest}, + cost_snapshot={"total_spent_usd": manifest["total_cost_usd"], + "total_reserved_usd": 0.0, + "budget_remaining_usd": round(4 - manifest["total_cost_usd"], 2)}) + cp("assets", "completed", {"asset_manifest": manifest}, human_approved=True) + + # edit + compose (render via ffmpeg slideshow from the frames) + edit = {"version": "1.0", "cuts": [], "metadata": {"note": "demo"}} + (art_dir / "edit_decisions.json").write_text(json.dumps(edit, indent=2)) + renders = pdir / "renders" + renders.mkdir(exist_ok=True) + first_frame = pdir / "assets" / "images" / f"{scenes[0][0]}.png" + subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", + "-i", str(first_frame), "-t", "4", "-vf", "scale=960:540", + "-pix_fmt", "yuv420p", str(renders / "final.mp4")], + check=False, timeout=60) + + +SCENES_LIGHTHOUSE = [ + ("sc1", "Opening — a lighthouse at dusk", 0, 4, "The coast holds its breath."), + ("sc2", "The beam sweeps the water", 4, 9, "Every night, the same promise."), + ("sc3", "A storm builds offshore", 9, 15, "Until the night the light went out."), + ("sc4", "The keeper climbs the stairs", 15, 21, "Someone still has to climb."), + ("sc5", "The lamp room, hands on glass", 21, 26, "And someone always does."), +] + +SCENES_STATIC = [ + ("sc1", "A radio tower against a violet sky", 0, 5, "The signal arrived at 3:14 a.m."), + ("sc2", "Rows of receivers, one glowing", 5, 10, "Nobody was listening. Except her."), + ("sc3", "Static resolving into a pattern", 10, 16, "Noise, she realized, was a language."), + ("sc4", "The pattern projected on a wall", 16, 22, "And it was asking a question."), +] + +SCENES_ORCHARD = [ + ("sc1", "An orchard in first light", 0, 5, "The trees keep a slower calendar."), + ("sc2", "Hands grafting a branch", 5, 11, "A graft is a promise to a future you won't see."), + ("sc3", "Seasons blurring over one tree", 11, 18, "Forty springs in a single trunk."), + ("sc4", "Fruit in a child's hand", 18, 24, "Somebody planted this for you."), +] + +SCENES_PAPER = [ + ("sc1", "A desk lamp over folded paper", 0, 4, "Every boat starts as a flat sheet."), + ("sc2", "Creases becoming a hull", 4, 9, "Twelve folds between idea and vessel."), + ("sc3", "The boat on dark water", 9, 15, "It will not survive the river."), + ("sc4", "Paper dissolving, ink blooming", 15, 20, "That was never the point."), +] + + +def build_stage() -> None: + if STAGE_DIR.exists(): + shutil.rmtree(STAGE_DIR) + STAGE_DIR.mkdir(parents=True) + stage_project("the-last-lighthouse", "The Last Lighthouse", "lighthouse", + SCENES_LIGHTHOUSE, state="complete", hero="sc3", takes_scene="sc3") + stage_project("signal-in-the-static", "Signal in the Static", "static", + SCENES_STATIC, state="assets_live", hero="sc3") + stage_project("the-slow-orchard", "The Slow Orchard", "orchard", + SCENES_ORCHARD, state="script_gate", hero="sc3") + stage_project("paper-boats", "Paper Boats", "paper", + SCENES_PAPER, state="early", hero="sc3") + print(f"[stage] built 4 demo projects in {STAGE_DIR}") + + +# --------------------------------------------------------------------------- +# screenshots +# --------------------------------------------------------------------------- + +SHOTS = [ + ("library", "/?static=1", 1560, 500, 4200), + ("board-live", "/p/signal-in-the-static?static=1", 1560, 1150, 4200), + ("script-gate", "/p/the-slow-orchard?static=1", 1560, 760, 3200), + ("storyboard", "/p/the-last-lighthouse?static=1", 1560, 1500, 4200), +] + + +def shoot() -> None: + env = dict(os.environ) + server = subprocess.Popen( + [sys.executable, "-m", "backlot", "serve", "--port", str(PORT)], + env=env, cwd=REPO_ROOT, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + deadline = time.time() + 20 + while time.time() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/api/health", timeout=1): + break + except Exception: + time.sleep(0.4) + SHOTS_DIR.mkdir(parents=True, exist_ok=True) + for name, path, w, h, wait_ms in SHOTS: + out = SHOTS_DIR / f"{name}.png" + subprocess.run( + ["npx", "playwright", "screenshot", + "--viewport-size", f"{w},{h}", + "--wait-for-timeout", str(wait_ms), + f"http://127.0.0.1:{PORT}{path}", str(out)], + check=True, timeout=120, shell=(os.name == "nt")) + print(f"[shot] {out}") + finally: + server.terminate() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--stage-only", action="store_true") + parser.add_argument("--shoot-only", action="store_true") + args = parser.parse_args() + if not args.shoot_only: + build_stage() + if not args.stage_only: + shoot() diff --git a/scripts/backlot_simulate_run.py b/scripts/backlot_simulate_run.py new file mode 100644 index 00000000..bec5b4e4 --- /dev/null +++ b/scripts/backlot_simulate_run.py @@ -0,0 +1,161 @@ +"""Simulate a pipeline run on disk to exercise the Backlot live board. + +Drives a fake production through the REAL contract — init_project, +in_progress checkpoints, gated awaiting_human states, tool events, +progressively-written artifacts — so the board can be watched updating live. +Also useful as a demo driver. + + python scripts/backlot_simulate_run.py [--project backlot-demo-run] + [--fast] [--cleanup] + +--fast compresses waits to ~0.3s (for automated verification) +--cleanup removes the project directory at the end +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from lib.checkpoint import PROJECTS_DIR, init_project, write_checkpoint +from lib.events import emit_event + +SCENES = [ + ("sc1", "Opening — a lighthouse at dusk", 0, 4, "The coast holds its breath."), + ("sc2", "The beam sweeps the water", 4, 9, "Every night, the same promise."), + ("sc3", "A storm builds offshore", 9, 15, "Until the night the light went out."), + ("sc4", "The keeper climbs the stairs", 15, 21, "Someone still has to climb."), +] + + +def artifacts_for(project_id: str) -> dict: + script = { + "version": "1.0", + "title": "The Last Lighthouse", + "total_duration_seconds": 21, + "sections": [ + {"id": f"s{i+1}", "label": desc.split("—")[0].strip(), "text": narration, + "start_seconds": s0, "end_seconds": s1} + for i, (sid, desc, s0, s1, narration) in enumerate(SCENES) + ], + } + scene_plan = { + "version": "1.0", + "scenes": [ + {"id": sid, "type": "generated", "description": desc, + "start_seconds": s0, "end_seconds": s1, + "script_section_id": f"s{i+1}", + "hero_moment": sid == "sc3", + "required_assets": [{"type": "image", "description": desc, "source": "generate"}]} + for i, (sid, desc, s0, s1, _n) in enumerate(SCENES) + ], + } + return {"script": script, "scene_plan": scene_plan} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--project", default="backlot-demo-run") + parser.add_argument("--fast", action="store_true") + parser.add_argument("--cleanup", action="store_true") + args = parser.parse_args() + + wait = 0.3 if args.fast else 2.5 + pid = args.project + pdir = PROJECTS_DIR / pid + if pdir.exists(): + shutil.rmtree(pdir) + + print(f"[sim] init_project {pid}") + init_project(pid, title="The Last Lighthouse", pipeline_type="cinematic", + style_playbook="clean-professional") + art = artifacts_for(pid) + + def save_artifact(name: str, data: dict) -> None: + path = pdir / "artifacts" / f"{name}.json" + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def cp(stage: str, status: str, artifacts: dict, **kw) -> None: + write_checkpoint(PROJECTS_DIR, pid, stage, status, artifacts, + pipeline_type="cinematic", **kw) + print(f"[sim] checkpoint {stage} -> {status}") + time.sleep(wait) + + # research auto-proceeds (schema-valid fixture from the contract tests) + cp("research", "in_progress", {}) + from tests.contracts.test_phase0_contracts import sample_artifact + brief = sample_artifact("research_brief") + brief["topic"] = "The Last Lighthouse" + cp("research", "completed", {"research_brief": brief}) + + # script gates: awaiting_human -> approved + cp("script", "in_progress", {}) + save_artifact("script", art["script"]) + cp("script", "awaiting_human", {"script": art["script"]}, + review={"round": 1, "decision": "pass", "critical": 0, "suggestions": 1, + "nitpicks": 0, "summary": "Hook is strong; tightened s3."}) + time.sleep(wait) # "user reads the script on the board" + cp("script", "completed", {"script": art["script"]}, human_approved=True) + + # scene_plan gates too + cp("scene_plan", "in_progress", {}) + save_artifact("scene_plan", art["scene_plan"]) + cp("scene_plan", "awaiting_human", {"scene_plan": art["scene_plan"]}) + time.sleep(wait) + cp("scene_plan", "completed", {"scene_plan": art["scene_plan"]}, human_approved=True) + + # assets: per-scene tool events + growing manifest + partial progress + cp("assets", "in_progress", {}) + manifest = {"version": "1.0", "assets": [], "total_cost_usd": 0.0} + done_ids = [] + from PIL import Image, ImageDraw + palette = [(24, 32, 48), (40, 30, 60), (60, 24, 24), (20, 48, 40)] + for i, (sid, desc, _s0, _s1, _n) in enumerate(SCENES): + emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": sid}) + print(f"[sim] generating {sid}…") + time.sleep(wait * 1.5) + rel = f"assets/images/{sid}.png" + img = Image.new("RGB", (640, 360), palette[i % 4]) + draw = ImageDraw.Draw(img) + draw.text((20, 160), f"{sid} — {desc[:40]}", fill=(230, 225, 210)) + img.save(pdir / rel) + emit_event(pdir, {"tool": "flux_image", "event": "finish", "scene_id": sid, + "success": True, "cost_usd": 0.05, "duration_s": wait * 1.5, + "output_path": rel}) + manifest["assets"].append({ + "id": f"img_{sid}", "type": "image", "path": rel, "scene_id": sid, + "source_tool": "flux_image", "model": "flux-sim", "cost_usd": 0.05, + "prompt": desc, "quality_score": 0.88, + }) + manifest["total_cost_usd"] = round(manifest["total_cost_usd"] + 0.05, 2) + save_artifact("asset_manifest", manifest) + done_ids.append(sid) + write_checkpoint(PROJECTS_DIR, pid, "assets", "in_progress", {}, + pipeline_type="cinematic", + metadata={"partial_progress": {"completed_scene_ids": done_ids}}, + cost_snapshot={"total_spent_usd": manifest["total_cost_usd"], + "total_reserved_usd": 0.0, + "budget_remaining_usd": 5 - manifest["total_cost_usd"]}) + # assets gate (the storyboard review) + cp("assets", "awaiting_human", {"asset_manifest": manifest}, + cost_snapshot={"total_spent_usd": manifest["total_cost_usd"], + "total_reserved_usd": 0.0, + "budget_remaining_usd": 5 - manifest["total_cost_usd"]}) + time.sleep(wait) + cp("assets", "completed", {"asset_manifest": manifest}, human_approved=True) + + print(f"[sim] done — board at http://127.0.0.1:4750/p/{pid}") + if args.cleanup: + shutil.rmtree(pdir) + print("[sim] cleaned up") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backlot_visual_eval.py b/scripts/backlot_visual_eval.py new file mode 100644 index 00000000..34bd0438 --- /dev/null +++ b/scripts/backlot_visual_eval.py @@ -0,0 +1,234 @@ +"""Deterministic visual eval for Backlot. + +Stages the fictional Backlot projects, captures canonical browser screenshots, +optionally compares them to goldens, and can run a small Playwright interaction +smoke against the staged board. + +Examples: + python scripts/backlot_visual_eval.py + python scripts/backlot_visual_eval.py --bless + python scripts/backlot_visual_eval.py --interactions +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any + +from PIL import Image, ImageChops + +REPO_ROOT = Path(__file__).resolve().parent.parent +STAGE_DIR = REPO_ROOT / ".backlot" / "screenshot-stage" +GOLDENS_DIR = REPO_ROOT / "internal" / "evals" / "goldens" +CAPTURE_ROOT = REPO_ROOT / "internal" / "evals" / "captures" +PORT = 4791 + +SHOTS = [ + ("library", "/?static=1", 1560, 500, 4200, [ + (1370, 20, 1510, 62), # live/idle badge + (90, 106, 422, 380), # card border/status animation variance + (440, 106, 772, 380), + (790, 106, 1122, 380), + (1140, 106, 1472, 380), + ]), + ("board-live", "/p/signal-in-the-static?static=1", 1560, 1150, 4200, []), + ("script-gate", "/p/the-slow-orchard?static=1", 1560, 760, 3200, []), + ("storyboard", "/p/the-last-lighthouse?static=1", 1560, 1500, 4200, []), +] + + +def compare_images( + expected_path: Path, + actual_path: Path, + diff_path: Path, + *, + threshold: float = 0.015, + masks: list[tuple[int, int, int, int]] | None = None, +) -> dict[str, Any]: + """Compare screenshots by changed-pixel ratio and write a red diff image.""" + expected = Image.open(expected_path).convert("RGB") + actual = Image.open(actual_path).convert("RGB") + if expected.size != actual.size: + diff_path.parent.mkdir(parents=True, exist_ok=True) + actual.save(diff_path) + return {"passed": False, "changed_ratio": 1.0, "reason": f"size {expected.size} != {actual.size}"} + + masks = masks or [] + for box in masks: + patch = expected.crop(box) + actual.paste(patch, box) + + delta = ImageChops.difference(expected, actual) + changed = 0 + pixels = delta.load() + width, height = delta.size + diff = Image.new("RGB", delta.size, (0, 0, 0)) + diff_px = diff.load() + for y in range(height): + for x in range(width): + if max(pixels[x, y]) > 8: + changed += 1 + diff_px[x, y] = (255, 40, 40) + else: + diff_px[x, y] = actual.getpixel((x, y)) + ratio = changed / float(width * height) + diff_path.parent.mkdir(parents=True, exist_ok=True) + diff.save(diff_path) + return {"passed": ratio <= threshold, "changed_ratio": round(ratio, 6), "threshold": threshold} + + +def run_stage() -> None: + subprocess.run( + [sys.executable, "scripts/backlot_screenshot_stage.py", "--stage-only"], + cwd=REPO_ROOT, + check=True, + timeout=180, + ) + + +def start_server() -> subprocess.Popen: + env = dict(os.environ) + env["OPENMONTAGE_PROJECTS_DIR"] = str(STAGE_DIR) + server = subprocess.Popen( + [sys.executable, "-m", "backlot", "serve", "--port", str(PORT)], + cwd=REPO_ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.time() + 20 + while time.time() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/api/health", timeout=1): + return server + except Exception: + time.sleep(0.3) + server.terminate() + raise RuntimeError("Backlot server did not become healthy") + + +def capture_screenshot(url: str, output: Path, width: int, height: int, wait_ms: int) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "npx", + "playwright", + "screenshot", + "--viewport-size", + f"{width},{height}", + "--wait-for-timeout", + str(wait_ms), + url, + str(output), + ], + cwd=REPO_ROOT, + check=True, + timeout=120, + shell=(os.name == "nt"), + ) + + +def capture_shots(capture_dir: Path) -> list[dict[str, Any]]: + results = [] + for name, path, width, height, wait_ms, _masks in SHOTS: + out = capture_dir / f"{name}.png" + capture_screenshot(f"http://127.0.0.1:{PORT}{path}", out, width, height, wait_ms) + results.append({"name": name, "path": out}) + return results + + +def compare_or_bless(capture_dir: Path, *, bless: bool, threshold: float) -> list[dict[str, Any]]: + GOLDENS_DIR.mkdir(parents=True, exist_ok=True) + report = [] + for name, _path, _width, _height, _wait_ms, masks in SHOTS: + actual = capture_dir / f"{name}.png" + golden = GOLDENS_DIR / f"{name}.png" + if bless or not golden.exists(): + shutil.copyfile(actual, golden) + report.append({"name": name, "status": "blessed", "golden": str(golden)}) + continue + diff = capture_dir / "diffs" / f"{name}.png" + result = compare_images(golden, actual, diff, threshold=threshold, masks=masks) + result.update({"name": name, "diff": str(diff)}) + report.append(result) + return report + + +def run_interactions(capture_dir: Path) -> dict[str, Any]: + """Run browser interaction smoke through Python Playwright.""" + from playwright.sync_api import sync_playwright + + screenshot = capture_dir / "interaction-smoke.png" + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1560, "height": 1000}) + page.goto(f"http://127.0.0.1:{PORT}/p/the-last-lighthouse?static=1") + page.wait_for_selector(".stage") + page.locator(".stage").first.click() + page.wait_for_selector(".drawer") + drawer_text = page.locator(".drawer").inner_text() + if "research" not in drawer_text: + raise RuntimeError("stage drawer did not open") + page.locator(".script-card").first.click() + page.wait_for_selector(".modal-bg.open") + page.keyboard.press("Escape") + page.wait_for_function("() => !document.querySelector('.modal-bg')?.classList.contains('open')") + if page.locator(".takes").count() < 1: + raise RuntimeError("takes drawer not present on staged takes scene") + replay_button = page.locator(".rp-btn", has_text="REPLAY RUN") + if replay_button.count(): + replay_button.first.click() + page.wait_for_selector('input[type="range"]') + page.locator('input[type="range"]').fill("500") + page.screenshot(path=str(screenshot), full_page=True) + browser.close() + return {"status": "passed", "screenshot": str(capture_dir / "interaction-smoke.png")} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bless", action="store_true", help="Write current captures as goldens") + parser.add_argument("--no-stage", action="store_true", help="Reuse existing .backlot/screenshot-stage") + parser.add_argument("--interactions", action="store_true", help="Run Playwright interaction smoke") + parser.add_argument("--threshold", type=float, default=0.015) + parser.add_argument("--out-dir", type=Path, default=None) + args = parser.parse_args(argv) + + if not args.no_stage: + run_stage() + + stamp = datetime.now().strftime("visual-%Y%m%d-%H%M%S") + capture_dir = args.out_dir or (CAPTURE_ROOT / stamp) + capture_dir.mkdir(parents=True, exist_ok=True) + + server = start_server() + try: + capture_shots(capture_dir) + report = compare_or_bless(capture_dir, bless=args.bless, threshold=args.threshold) + interaction_report = run_interactions(capture_dir) if args.interactions else None + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + + passed = all(item.get("passed", item.get("status") == "blessed") for item in report) + payload = {"capture_dir": str(capture_dir), "shots": report, "interactions": interaction_report} + report_path = capture_dir / "report.json" + report_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backlot_watch_captures.py b/scripts/backlot_watch_captures.py new file mode 100644 index 00000000..0885276b --- /dev/null +++ b/scripts/backlot_watch_captures.py @@ -0,0 +1,195 @@ +"""Capture Backlot board screenshots whenever watched project state changes. + +This is the Half-B dogfood watcher from internal/evals/BACKLOT_EVAL_PLAN.md. +It polls the Backlot API, fingerprints board-relevant state, and captures the +library plus the changed project board through Playwright. + +Example: + python scripts/backlot_watch_captures.py --projects why-cities-glow rain-on-glass +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_BASE_URL = "http://127.0.0.1:4750" +DEFAULT_CAPTURE_ROOT = REPO_ROOT / "internal" / "evals" / "captures" + + +def capture_slug(project_id: str, stage: str | None, status: str | None) -> str: + """Stable, filesystem-safe screenshot name stem.""" + raw = "-".join(part for part in (project_id, stage or "unknown", status or "unknown") if part) + raw = raw.replace("\\", "-").replace("/", "-").replace("..", "") + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-") + slug = re.sub(r"-{2,}", "-", slug) + return slug or "capture" + + +def state_fingerprint(state: dict[str, Any]) -> str: + """Hashable representation of board-visible state. + + Intentionally ignores mtime-ish noise such as last_activity while keeping + the pieces that should trigger a capture: stage transitions, generating + flags, scene visual changes, costs, renders, and event count/tail. + """ + scenes = [] + storyboard = state.get("storyboard") or {} + for card in storyboard.get("scenes") or []: + visual = card.get("visual") or {} + scenes.append({ + "id": card.get("id"), + "generating": bool(card.get("generating")), + "generating_tool": card.get("generating_tool"), + "visual": { + "path": visual.get("path"), + "exists": visual.get("exists"), + "type": visual.get("type"), + }, + "takes": [take.get("path") for take in (card.get("takes") or [])], + "audio": [asset.get("path") for asset in (card.get("audio") or [])], + }) + + media = state.get("media") or {} + events = state.get("events") or [] + visible = { + "stages": [ + { + "name": stage.get("name"), + "status": stage.get("status"), + "gate_skipped": stage.get("gate_skipped"), + "versions": stage.get("versions"), + "partial_progress": stage.get("partial_progress"), + } + for stage in state.get("stages") or [] + ], + "scenes": scenes, + "cost": state.get("cost"), + "renders": [r.get("path") for r in media.get("renders") or []], + "snapshots": [s.get("path") for s in media.get("snapshots") or []], + "event_count": len(events), + "event_tail": events[-3:], + } + return json.dumps(visible, sort_keys=True, default=str, separators=(",", ":")) + + +def active_stage(state: dict[str, Any]) -> tuple[str | None, str | None]: + for stage in state.get("stages") or []: + if stage.get("status") in {"in_progress", "awaiting_human", "failed", "blocked"}: + return stage.get("name"), stage.get("status") + for stage in reversed(state.get("stages") or []): + if stage.get("status") == "completed": + return stage.get("name"), stage.get("status") + return None, None + + +def fetch_json(base_url: str, path: str) -> dict[str, Any] | list[Any]: + with urllib.request.urlopen(f"{base_url.rstrip('/')}{path}", timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + + +def capture_url(url: str, output: Path, *, width: int = 1560, height: int = 1150, wait_ms: int = 1200) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "npx", + "playwright", + "screenshot", + "--viewport-size", + f"{width},{height}", + "--wait-for-timeout", + str(wait_ms), + url, + str(output), + ], + cwd=REPO_ROOT, + check=True, + timeout=120, + shell=(os.name == "nt"), + ) + + +def capture_project(base_url: str, capture_dir: Path, project_id: str, seq: int, state: dict[str, Any]) -> None: + stage, status = active_stage(state) + stem = f"{seq:03d}-{capture_slug(project_id, stage, status)}" + capture_url(f"{base_url.rstrip('/')}/?static=1", capture_dir / "library" / f"{stem}.png", height=620) + capture_url( + f"{base_url.rstrip('/')}/p/{project_id}?static=1", + capture_dir / project_id / f"{stem}.png", + ) + + +def watch( + projects: list[str], + *, + base_url: str, + capture_dir: Path, + interval_s: float, + once: bool = False, + no_screenshots: bool = False, +) -> int: + fingerprints: dict[str, str] = {} + seq = 0 + capture_dir.mkdir(parents=True, exist_ok=True) + print(f"[watch] base={base_url} captures={capture_dir}") + while True: + changed = False + for project_id in projects: + try: + state = fetch_json(base_url, f"/api/project/{project_id}/state") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + print(f"[watch] {project_id}: state fetch failed: {exc}", file=sys.stderr) + continue + fp = state_fingerprint(state) + if fingerprints.get(project_id) == fp: + continue + fingerprints[project_id] = fp + changed = True + seq += 1 + stage, status = active_stage(state) + print(f"[watch] change {project_id}: {stage or 'unknown'} -> {status or 'unknown'}") + if not no_screenshots: + capture_project(base_url, capture_dir, project_id, seq, state) + if once: + return 0 + if not changed: + time.sleep(interval_s) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--projects", nargs="+", required=True, help="Project ids to watch") + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) + parser.add_argument("--interval", type=float, default=20.0, help="Polling interval in seconds") + parser.add_argument("--out-dir", type=Path, default=None) + parser.add_argument("--once", action="store_true", help="Poll once and exit") + parser.add_argument("--no-screenshots", action="store_true", help="Exercise polling without Playwright") + args = parser.parse_args(argv) + + out_dir = args.out_dir + if out_dir is None: + stamp = datetime.now().strftime("dogfood-%Y%m%d-%H%M%S") + out_dir = DEFAULT_CAPTURE_ROOT / stamp + return watch( + args.projects, + base_url=args.base_url, + capture_dir=out_dir, + interval_s=args.interval, + once=args.once, + no_screenshots=args.no_screenshots, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/INDEX.md b/skills/INDEX.md index a33fbab1..c8a05090 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -281,6 +281,7 @@ Cross-cutting skills that apply to all pipelines: | Checkpoint Protocol | `meta/checkpoint-protocol.md` | When/how to checkpoint and request human approval | | Skill Creator | `meta/skill-creator.md` | Dynamically create new skills during pipeline runs | | Animation Runtime Selector | `meta/animation-runtime-selector.md` | Choose render runtime + animation library per scene | +| Taste Direction | `meta/taste-direction.md` | Convert a brief into taste dials, anti-patterns, and reference strategy for proposal/playbook/atelier work | | Bespoke Composition (Atelier) | `meta/bespoke-composition.md` | Hand-author a composition from scratch (hero work) — no stock scene-types; routes art-direction → motion principles → engine mechanics → atelier render | ## Style Playbooks @@ -290,6 +291,7 @@ Style playbooks (`styles/*.yaml`) define visual language, typography, motion, au | Playbook | Category | Mood | Best For | |----------|----------|------|----------| | `clean-professional` | motion-graphics | polished, trustworthy | Corporate, educational, SaaS | +| `premium-minimalist` | minimalist | calm, editorial | Investor updates, expert explainers, product narratives | | `flat-motion-graphics` | motion-graphics | energetic, bold | Social media, TikTok, startups | | `minimalist-diagram` | whiteboard | focused, technical | Technical deep-dives, architecture | diff --git a/skills/creative/data-visualization.md b/skills/creative/data-visualization.md index 73d4bdd0..7648d3d9 100644 --- a/skills/creative/data-visualization.md +++ b/skills/creative/data-visualization.md @@ -12,7 +12,7 @@ clear, accurate, and effective in video. | Tool | Role | |------|------| | `diagram_gen` | Generate charts via Mermaid or D3 | -| `image_selector` | Generate stylized chart illustrations (FLUX/DALL-E) | +| `image_selector` | Generate stylized chart illustrations (FLUX/GPT Image) | | Remotion | Animated chart components (bar grow, line draw, pie fill) | | Manim | Mathematical plots, coordinate systems, function graphs | diff --git a/skills/creative/image-gen-usage.md b/skills/creative/image-gen-usage.md index 018de149..3b263d40 100644 --- a/skills/creative/image-gen-usage.md +++ b/skills/creative/image-gen-usage.md @@ -1,6 +1,6 @@ # Image Generation Usage for OpenMontage -> Sources: OpenAI DALL-E 3 documentation, FLUX/BFL API documentation, existing Layer 3 skills +> Sources: OpenAI GPT Image documentation, FLUX/BFL API documentation, existing Layer 3 skills > at `.agents/skills/flux-best-practices/` and `.agents/skills/bfl-api/` ## Quick Reference Card @@ -150,7 +150,7 @@ optimized for image/video generation providers. ## Common Pitfalls 1. **Text in images** — AI image generators are unreliable with text. Never include text in prompts; add text as overlays in the compose stage -2. **Hands and fingers** — DALL-E 3 and FLUX still struggle. Avoid prompts requiring detailed hand poses +2. **Hands and fingers** — AI image models still struggle. Avoid prompts requiring detailed hand poses 3. **Inconsistent characters** — Without reference images, the same character will look different each time. Always use the hero reference strategy 4. **Over-prompting** — Long, complex prompts produce unpredictable results. Keep to 2-3 sentences 5. **Over-unifying prompts** — Forcing the exact same style phrase into every prompt makes scenes look samey. Keep the visual system consistent, but let each scene express its own subject, shot, and emotional beat. diff --git a/skills/creative/image-provider-usage.md b/skills/creative/image-provider-usage.md index 179e64c0..47f842a3 100644 --- a/skills/creative/image-provider-usage.md +++ b/skills/creative/image-provider-usage.md @@ -11,7 +11,7 @@ |------|----------|------|-------|----------| | `flux_image` | FLUX 2 Pro via fal.ai | ~$0.03-0.05 | ~5-10s | Photorealism, general purpose, workhorse | | `grok_image` | Grok Imagine Image (xAI) | $0.02/output + $0.002/input edit image | ~5-15s | Image edits, style transfer, multi-image compositing | -| `openai_image` | GPT Image 1 (OpenAI) | ~$0.01-0.17 | ~5-15s | Complex instructions, text in images, multi-element | +| `openai_image` | GPT Image 2 (OpenAI) | ~$0.01-0.21 | ~5-15s | Complex instructions, text in images, multi-element | | `recraft_image` | Recraft V4 via fal.ai | ~$0.04-0.25 | ~5-10s | Logos, SVG vectors, brand assets, text rendering (see caveat below) | | `local_diffusion` | Stable Diffusion (local) | Free | ~30s+ | Offline, privacy, free | | `image_gen` | Multi (legacy, deprecated) | Varies | Varies | **Deprecated** — use `image_selector` or per-provider tools | @@ -39,7 +39,7 @@ | **Style transfer / repaint of an existing image** | `grok_image` | Native edit flow, strong promptable transforms | `openai_image` | | **Multi-image merge / composite** | `grok_image` | Can combine multiple source images into one scene | `openai_image` | | **Logo or brand asset** | `recraft_image` | SVG support, text accuracy | `openai_image` | -| **Image with text/labels** | `openai_image` | Best text rendering (GPT Image 1) | `recraft_image` | +| **Image with text/labels** | `openai_image` | Best text rendering (GPT Image 2) | `recraft_image` | | **Complex multi-element composition** | `openai_image` | Best instruction following | `flux_image` | | **Hero image (key visual)** | `flux_image` | Highest visual quality | `openai_image` | | **Thumbnail** | `flux_image` or `recraft_image` | Needs to be eye-catching | — | @@ -59,7 +59,7 @@ PRODUCTION PATH: Premium ├── Hero images: flux_image ($0.05/img) ├── Supporting visuals: flux_image ($0.03/img) -├── Text overlays: openai_image ($0.04/img) +├── Text overlays: openai_image ($0.05/img medium) ├── B-roll stills: pexels_image ($0.00) └── Total for 10 images: ~$0.35 diff --git a/skills/meta/bespoke-composition.md b/skills/meta/bespoke-composition.md index c2cadf9b..84c8a9cf 100644 --- a/skills/meta/bespoke-composition.md +++ b/skills/meta/bespoke-composition.md @@ -43,7 +43,9 @@ Author in this order. Each step routes you to existing knowledge — do not skip ### 1. Commit to an art direction *for this subject* — the divergence engine Before writing any component, decide a visual language that fits **this** topic and no other. -Use the **`visual-style`** Layer 3 skill (CREATE mode) to lock: palette, type personality, +Read **`skills/meta/taste-direction.md`** first and write the `taste_profile`: the design read, +`visual_variance`, `motion_intensity`, `information_density`, reference strategy, and +anti-patterns. Then use the **`visual-style`** Layer 3 skill (CREATE mode) to lock: palette, type personality, motion character, layout system, and **one signature device** unique to this piece. Difference between videos is guaranteed here — not by withholding components, but by forcing a fresh direction each time. Write it down (a short `art-direction.md` in the project) and build to it. @@ -224,8 +226,27 @@ registry (`src/components`, `src/Explainer`, etc.), and warns if `art_direction` so the user opts in knowingly. Quality varies more without a stock baseline — mitigate with strong principle skills (above) and the distinctness review, not by reintroducing reuse. - **Checkpoint cadence.** Follow `skills/meta/checkpoint-protocol.md`: present script + scene plan - for approval BEFORE generating assets, then a footage/asset checkpoint, then a first-render - checkpoint. Do not batch-generate ahead of sign-off. + for approval BEFORE generating assets, then the **assets gate**, then a first-render checkpoint. + Do not batch-generate ahead of sign-off, and **do not render a draft to earn the assets review** — + the assets gate is held *before* compose (see below). + +- **Populate the filmstrip with per-scene stills at the assets gate.** A bespoke scene's "asset" is + a `.tsx` composition — not thumbnailable — so the board can't show it until a still exists. Once + the composition compiles, render one still per scene at a representative frame into + `projects//snapshots/.png`, so the assets-gate filmstrip shows real frames instead + of "◆ BESPOKE" placeholders. Use Remotion's still renderer (fast — one frame each), driven off the + scene_plan timings: + + ```bash + # one still per scene at mid-scene frame (fps * mid_seconds), into snapshots/.png + npx remotion still projects//index.tsx \ + projects//snapshots/.png \ + --frame= --props= --public-dir= + ``` + + A helper that reads the scene_plan and renders all stills is at + `scripts/atelier_snapshots.py` (`python scripts/atelier_snapshots.py `). Then STOP at the + assets gate. The full/draft render is the **compose** stage, after approval. ## Worked precedents (for the *workflow*, not the look) diff --git a/skills/meta/checkpoint-protocol.md b/skills/meta/checkpoint-protocol.md index 700be44f..c2674f66 100644 --- a/skills/meta/checkpoint-protocol.md +++ b/skills/meta/checkpoint-protocol.md @@ -49,10 +49,33 @@ write_checkpoint( The checkpoint utility will: - Validate the artifact against its schema +- Enforce the approval gate (a gated stage cannot be written `completed` without `human_approved=True`) +- Archive any superseded checkpoint to `projects//history/` (stage versions and gate transitions are never destroyed) - Write the checkpoint JSON to disk - Include timestamp and stage metadata -### Step 4: Intra-Stage Checkpointing (Resume Support) +Canonical location: `projects//checkpoint_.json` — always +pass the repo's `projects/` directory as `pipeline_dir` (or use +`lib.checkpoint.PROJECTS_DIR`). Always pass `pipeline_type` — gate enforcement +reads the manifest through it. + +At pipeline initialization (before any stage), call `init_project()`: + +```python +from lib.checkpoint import init_project +init_project("my-project", title="My Project", pipeline_type="cinematic") +``` + +This creates the canonical directory layout and writes `project.json` — the +marker the Backlot board needs to show the project before its first +checkpoint. Then launch the board: `python -m backlot open my-project` +(non-fatal if unavailable — the board is an observer, never a blocker). + +### Step 4: Intra-Stage Checkpointing (Resume Support + Liveness) + +**On entering any stage, write an `in_progress` checkpoint first.** This is +what tells the user (via the Backlot board) that the stage is live rather +than stalled — certainty matters more than speed. Long-running stages (like `assets` or `compose` loops) can fail midway due to API errors, rate limits, or session interruptions. To allow resuming from the exact point of failure (e.g., Scene 4): @@ -78,14 +101,23 @@ Long-running stages (like `assets` or `compose` loops) can fail midway due to AP ### Step 5: Human Approval (If Required) +**The manifest value is binding.** `human_approval_default` in the pipeline +manifest is the single source of truth for whether a stage gates. This skill +never overrides it, and neither do you — there is no "this case is different." +(`lib/checkpoint.py` enforces this: writing `status="completed"` for a gated +stage without `human_approved=True` raises a `GATE VIOLATION` error.) + When `human_approval_default: true`: -1. **Present a summary** to the human: +1. **Write the checkpoint with `status="awaiting_human"`** (not `completed`). + +2. **Present a summary** to the human: ``` - ## Stage Complete: [stage_name] + ## Stage Complete: [stage_name] — awaiting your approval ### Artifact Summary [Key details from the artifact — title, duration, key decisions] + [If the Backlot board is running, point to it: the artifact renders there] ### Review Findings [Summary from reviewer: N critical (all fixed), N suggestions] @@ -97,19 +129,42 @@ When `human_approval_default: true`: Please review and approve to continue, or provide feedback for revision. ``` -2. **Wait for human response:** - - **Approved** → update checkpoint status to `"completed"`, proceed to next stage - - **Revision requested** → go back to the stage director skill with the human's feedback, produce revised artifacts, re-review, re-checkpoint +3. **END YOUR TURN.** Performing any further pipeline work in the same + response is a gate violation. "Present and continue" is not waiting — + the turn must end with the question, and the next pipeline action must + be caused by the user's reply. + +4. **On the user's response:** + - **Approved** → re-write the checkpoint with `status="completed"`, + `human_approved=True`, then proceed to the next stage + - **Revision requested** → go back to the stage director skill with the + human's feedback, produce revised artifacts, re-review, re-checkpoint + (the superseded checkpoint is preserved automatically in `history/`) - **Abort** → stop the pipeline -3. **Approval stages** (which stages typically need human approval): - - `idea` — Always. The creative direction defines everything downstream. - - `script` — Always. The words are the foundation. - - `scene_plan` — Usually. Visual choices are subjective. - - `assets` — Rarely. Automated quality checks are sufficient. - - `edit` — Rarely. Technical assembly, not creative. - - `compose` — Rarely. But human may want to preview. - - `publish` — Always. Human must approve before anything goes public. +5. **Approval is per-gate.** A prior approval, however broad ("looks great, + go ahead and make the whole thing"), never covers a later gate. If the + user explicitly pre-authorizes the full run, record that as a + `decision_log` entry (`category: "approval_policy"`) at the moment they + say it — absent that entry, stop at every gate. + +6. **The assets gate reviews the storyboard — before any draft render.** + `assets` now gates in every pipeline: present the generated assets + scene-by-scene (the Backlot board's filmstrip is the natural review + surface), including spend so far and the projected compose cost. A bad + asset caught here saves a full re-render. + + **Do not render a draft/full composition to earn this review.** The review + surface is the filmstrip populated with per-scene assets — stock picks, + generated stills, narration waveforms — *not* a rendered video. For scenes + whose "asset" is a bespoke/atelier composition (no thumbnailable file), the + agent writes one **per-scene review still** to + `projects//snapshots/.png` (a `remotion still` at a + representative frame — see `skills/meta/bespoke-composition.md`); the board + shows those on the filmstrip. Refresh `metadata.partial_progress` as stills + land, then STOP at the gate. The draft/final render is the **compose** + stage — it runs only after the assets gate is approved. Rendering a full + draft inside the assets stage jumps the gate the user is meant to hold. ### Step 6: Determine Next Stage diff --git a/skills/meta/reviewer.md b/skills/meta/reviewer.md index f47f1310..576b4941 100644 --- a/skills/meta/reviewer.md +++ b/skills/meta/reviewer.md @@ -59,6 +59,17 @@ If a style playbook is active, verify: Each violation is a **suggestion** severity finding. +### Step 4b: Taste Direction Review + +If `proposal_packet.production_plan.taste_profile` or the active playbook's `taste_profile` exists, verify: +- [ ] `design_read` explains the brief, audience, and delivery promise; it is not just "modern/clean/professional" +- [ ] `visual_variance`, `motion_intensity`, and `information_density` are reflected in scene layout, pacing, callout density, and asset prompts +- [ ] `reference_strategy` is present when atelier work, AI image/video, product/brand visuals, or mood boards depend on visual nuance +- [ ] Listed `anti_patterns` are actually avoided +- [ ] Quality gates are concrete enough for the next stage to enforce + +At proposal stage, a missing `taste_profile` is a **suggestion** for preset/low-stakes work and a **critical** finding for atelier, product/brand, launch, hero, or custom-playbook work. At scene_plan/edit/compose, treat dial violations as **suggestion** unless they break the approved delivery promise. + ### Step 5: Evaluate Success Criteria For each `success_criteria` item from the manifest: diff --git a/skills/meta/taste-direction.md b/skills/meta/taste-direction.md new file mode 100644 index 00000000..a870a3f4 --- /dev/null +++ b/skills/meta/taste-direction.md @@ -0,0 +1,126 @@ +# Taste Direction - Meta Skill + +## When to Use + +Use this before committing to visual identity, mood boards, proposal packets, custom playbooks, atelier composition, image reference batches, or brand-heavy videos. + +This skill defines OpenMontage's video taste profile contract. Do not treat it as a frontend style recipe. The output is a compact video taste profile that travels through proposal, scene planning, asset prompts, edit, compose, and review. + +## Output Contract + +Write a `taste_profile` when the proposal or playbook needs a stronger creative contract: + +```json +{ + "design_read": "Premium expert explainer: calm authority, high trust, low ornament.", + "visual_variance": 4, + "motion_intensity": 3, + "information_density": 5, + "palette_discipline": "Neutral base, one accent, no decorative gradients.", + "layout_variation": "Alternate editorial split frames with data-forward full-frame scenes.", + "reference_strategy": "One reference still per scene family before asset generation.", + "anti_patterns": ["generic AI-purple gradient backgrounds"], + "quality_gates": ["Every scene should carry the design read without explanatory labels."] +} +``` + +`visual_variance`, `motion_intensity`, and `information_density` are 1-10 integer dials: + +| Dial | Low | Mid | High | +|------|-----|-----|------| +| `visual_variance` | Tight system, repeated grammar | Pattern with purposeful scene families | Each beat may use a distinct visual mode | +| `motion_intensity` | Calm holds, small transitions | Clear motion accents and reveals | Fast kinetic language, frequent directional changes | +| `information_density` | One idea per frame | Main idea plus support detail | Dense dashboards, diagrams, or layered callouts | + +## Process + +### 1. Make a Design Read + +Before choosing a playbook or palette, state what the video needs to feel like and why. Tie it to the audience, promise, platform, and subject matter. + +Good reads are specific: + +- "Investor-facing AI launch: precise, restrained, and credible; avoid hype visuals." +- "Youth science short: bright, curious, and kinetic; make invisible physics feel tactile." +- "Security incident explainer: tense and surgical; high contrast, low ornament, readable evidence." + +Weak reads are only adjectives: + +- "modern and clean" +- "cinematic" +- "professional" + +### 2. Set the Three Dials + +Pick `visual_variance`, `motion_intensity`, and `information_density` before writing concepts. These numbers should explain later choices: + +- High motion plus low information means short kinetic beats, not dense diagrams. +- Low motion plus high information means stable frames, chart builds, and long readable holds. +- High variance means scene families need stronger anchors: recurring type, palette, framing, or sound motif. + +### 3. Choose a Style Path + +Use the dials to pick one of three paths: + +| Path | Use When | Artifact | +|------|----------|----------| +| Existing playbook | A preset honestly matches the read | `production_plan.playbook` | +| Custom playbook | The subject has its own visual world | generated `styles/.yaml` with `taste_profile` | +| Atelier art direction | Hero work needs a one-off language | `production_plan.art_direction` plus `taste_profile` | + +Do not let preset availability override the design read. If the content calls for a custom visual world, write the custom playbook or art direction. + +### 4. Plan References + +If the work uses AI image/video, mood boards, brand assets, or atelier composition, create a reference strategy: + +- Use one reference still per scene family or major beat. +- Do not compress the whole direction into one mood board image. +- For brand/product work, create or inspect a brand kit before asset generation. +- For screen demos, inspect the real UI and write a redesign/audit note before styling overlays. + +### 5. Carry the Profile Downstream + +At proposal stage: + +- Add `production_plan.taste_profile`. +- Log style/playbook selection in `decision_log`. +- Explain how the dials affect runtime, composition mode, and asset generation. + +At scene planning: + +- Vary layouts according to `visual_variance`. +- Keep on-screen text and callouts within `information_density`. +- Set transition families and camera movement from `motion_intensity`. + +At assets: + +- Include palette, texture, framing, and reference strategy in image/video prompts. +- Generate reference stills before full batches when the profile depends on visual nuance. + +At edit/compose: + +- Match hold times and cut rhythm to the motion dial. +- Avoid adding decorative overlays that violate the design read. + +## Anti-Default Checklist + +Flag these before moving forward: + +- Generic AI-purple gradients or default corporate-blue visuals with no subject reason. +- Same transition on every cut when `visual_variance` is 4 or higher. +- Kinetic motion that makes narration harder to follow. +- Dense callouts when `information_density` is 4 or lower. +- Text-only slides unless the design read intentionally calls for typographic storytelling. +- Mood boards that look attractive but do not map to concrete scene families. +- Brand/product videos that never show or inspect the real brand/product surface. + +## Review Hooks + +Reviewer should check: + +- Does `taste_profile.design_read` explain a real creative choice? +- Do scene plans and edits respect the three dials? +- Are anti-patterns actually avoided? +- Is the reference strategy present when AI images/video or atelier work depends on visual nuance? +- Could this video belong to any topic after replacing the title? If yes, the taste direction is too generic. diff --git a/skills/pipelines/animation/asset-director.md b/skills/pipelines/animation/asset-director.md index 26830c00..59ab5c18 100644 --- a/skills/pipelines/animation/asset-director.md +++ b/skills/pipelines/animation/asset-director.md @@ -159,8 +159,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/animation/idea-director.md b/skills/pipelines/animation/idea-director.md index 424eb472..f8f6cbae 100644 --- a/skills/pipelines/animation/idea-director.md +++ b/skills/pipelines/animation/idea-director.md @@ -71,3 +71,12 @@ Recommended metadata keys: - Treating all animation as one generic category. - Planning bespoke visuals for every scene. - Hiding missing tool paths until the asset stage. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/animation/proposal-director.md b/skills/pipelines/animation/proposal-director.md index 5dc7bdeb..caffcc8c 100644 --- a/skills/pipelines/animation/proposal-director.md +++ b/skills/pipelines/animation/proposal-director.md @@ -46,6 +46,7 @@ A `render_runtime_selection` decision with only one option considered when both | Tool registry | `support_envelope()` output | What's actually available right now | | Cost tracker | `tools/cost_tracker.py` | Cost estimation data | | Style playbooks | `styles/*.yaml` | Available visual styles | +| Meta skill | `skills/meta/taste-direction.md` | Design read, taste dials, reference strategy | | User input | Topic, any preferences expressed | Creative direction | ## Process @@ -121,6 +122,8 @@ Record all findings. **Do not propose an animation mode that requires tools you This is the key differentiator from the explainer proposal. **Present the user with concrete animation approaches, explain what each looks like, what tools/keys they need, and what's already available.** +Before locking animation mode or visual identity, read `skills/meta/taste-direction.md` and write a `production_plan.taste_profile`. The three dials (`visual_variance`, `motion_intensity`, `information_density`) should explain whether the concept needs calm data builds, kinetic typography, dense diagrams, reference stills, or a custom/atelier visual system. + #### Step 3a: Tool Availability Scan Before designing concepts, scan what's available and present it honestly. **Do NOT hardcode provider names, costs, or key names in this output** — they drift. Read them live from the registry: @@ -278,7 +281,7 @@ For each concept, specify: For each concept, specify: - **Animation approach**: `image_animation` / `clip_video` / `manim` / `remotion_dataviz` / `diagram_stills` / `mixed` - **Why this approach**: grounded in technique research AND tool availability from Step 3 -- **Image/video generation provider**: which specific provider from the preflight scan (e.g., "FLUX via fal.ai", "gpt-image-1 via OpenAI", "Stable Diffusion local") +- **Image/video generation provider**: which specific provider from the preflight scan (e.g., "FLUX via fal.ai", "gpt-image-2 via OpenAI", "Stable Diffusion local") - **Reuse strategy**: What's the visual system? (recurring motifs, layout grid, color scheme, transition family) - **Complexity estimate**: How many unique scene types vs. reusable templates? - **Visual identity**: palette, typography, texture, motion energy, and why they fit this subject, audience, and platform @@ -461,8 +464,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/animation/publish-director.md b/skills/pipelines/animation/publish-director.md index 1048201c..c670a93e 100644 --- a/skills/pipelines/animation/publish-director.md +++ b/skills/pipelines/animation/publish-director.md @@ -43,3 +43,12 @@ Store in `publish_log.metadata`: - Writing generic metadata that ignores the animation style. - Creating a thumbnail concept unrelated to the final frames. - Mixing platform variants without clear labels. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/animation/scene-director.md b/skills/pipelines/animation/scene-director.md index f5a247fe..7245fd63 100644 --- a/skills/pipelines/animation/scene-director.md +++ b/skills/pipelines/animation/scene-director.md @@ -113,3 +113,12 @@ Recommended metadata keys: - Adding a new transition idea in every scene. - Planning scenes that have no realistic production path. - Overanimating text-heavy scenes. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/animation/script-director.md b/skills/pipelines/animation/script-director.md index 11f56ef1..11c8c9f1 100644 --- a/skills/pipelines/animation/script-director.md +++ b/skills/pipelines/animation/script-director.md @@ -134,3 +134,12 @@ add the source. Do not invent statistics, dates, or attributions. - **Ignoring the animation mode.** A Manim script reads differently than an AI video script. - **Writing research-less scripts when a research_brief exists.** If the research found surprising data, use it. Generic scripts waste the research investment. - **Oversimplifying math to the point of being wrong.** Check the research brief's accuracy notes. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/avatar-spokesperson/asset-director.md b/skills/pipelines/avatar-spokesperson/asset-director.md index 9b2ca32d..fb7a2d92 100644 --- a/skills/pipelines/avatar-spokesperson/asset-director.md +++ b/skills/pipelines/avatar-spokesperson/asset-director.md @@ -124,8 +124,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/avatar-spokesperson/idea-director.md b/skills/pipelines/avatar-spokesperson/idea-director.md index e1a84e84..040c2240 100644 --- a/skills/pipelines/avatar-spokesperson/idea-director.md +++ b/skills/pipelines/avatar-spokesperson/idea-director.md @@ -77,3 +77,12 @@ Recommended metadata keys: - Treating a generic generated-video request as a deterministic avatar workflow. - Writing the CTA before confirming the avatar and narration path. - Planning multiple aspect ratios before the hero layout is proven. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/avatar-spokesperson/publish-director.md b/skills/pipelines/avatar-spokesperson/publish-director.md index a3005f79..0a6d7103 100644 --- a/skills/pipelines/avatar-spokesperson/publish-director.md +++ b/skills/pipelines/avatar-spokesperson/publish-director.md @@ -42,3 +42,12 @@ If the avatar path has limitations such as visible lip-sync risk, retain that no - Mixing hero and derivative exports without clear naming. - Reusing generic metadata that ignores the spokesperson offer. - Dropping risk notes that matter for downstream publishing teams. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/avatar-spokesperson/scene-director.md b/skills/pipelines/avatar-spokesperson/scene-director.md index e827ff22..f224ba73 100644 --- a/skills/pipelines/avatar-spokesperson/scene-director.md +++ b/skills/pipelines/avatar-spokesperson/scene-director.md @@ -80,3 +80,12 @@ When the EP triggers a no-avatar pivot (no `talking_head` or `lip_sync` availabl - Filling empty space with decorative panels. - Assuming a landscape presenter layout will survive a vertical crop untouched. - (Fallback mode) Producing a wall of text on screen to compensate for no presenter — let the narration carry the content. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/avatar-spokesperson/script-director.md b/skills/pipelines/avatar-spokesperson/script-director.md index aab93102..cb1aa017 100644 --- a/skills/pipelines/avatar-spokesperson/script-director.md +++ b/skills/pipelines/avatar-spokesperson/script-director.md @@ -74,3 +74,12 @@ add the source. Do not invent statistics, dates, or attributions. - Overstuffing one scene because the script reads well on paper. - Duplicating the same sentence in speech and large text overlays. - Writing humor or improvisational beats the avatar path cannot sell. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/character-animation/asset-director.md b/skills/pipelines/character-animation/asset-director.md index c6161a0b..6a76d70e 100644 --- a/skills/pipelines/character-animation/asset-director.md +++ b/skills/pipelines/character-animation/asset-director.md @@ -55,3 +55,12 @@ projects//assets/backgrounds/ All parts referenced by `rig_plan` must exist before compose. Missing parts are a blocker unless the action timeline removes the action requiring them. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/character-animation/character-design-director.md b/skills/pipelines/character-animation/character-design-director.md index af54377f..27a38dac 100644 --- a/skills/pipelines/character-animation/character-design-director.md +++ b/skills/pipelines/character-animation/character-design-director.md @@ -32,3 +32,12 @@ using image generation, read the tool's Layer 3 skills from the registry. A character design is ready only when an animator or tool can infer what parts, expressions, and actions must exist. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/character-animation/proposal-director.md b/skills/pipelines/character-animation/proposal-director.md index b250c221..55c490f9 100644 --- a/skills/pipelines/character-animation/proposal-director.md +++ b/skills/pipelines/character-animation/proposal-director.md @@ -59,3 +59,12 @@ Report the difference: - TTS/music cost, - local render cost, - manual complexity risk. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/character-animation/publish-director.md b/skills/pipelines/character-animation/publish-director.md index 6ddf2346..52a0deaa 100644 --- a/skills/pipelines/character-animation/publish-director.md +++ b/skills/pipelines/character-animation/publish-director.md @@ -24,3 +24,12 @@ Produce `publish_log` with: - description, - platform-specific export notes, - limitations or follow-up recommendations. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/character-animation/scene-director.md b/skills/pipelines/character-animation/scene-director.md index 1bd57339..c1b2063e 100644 --- a/skills/pipelines/character-animation/scene-director.md +++ b/skills/pipelines/character-animation/scene-director.md @@ -35,3 +35,12 @@ Prefer fewer, stronger shots: Avoid scenes that require many unique views or complex physical contact unless the user approved that complexity. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/character-animation/script-director.md b/skills/pipelines/character-animation/script-director.md index c1c310ed..0c784f1e 100644 --- a/skills/pipelines/character-animation/script-director.md +++ b/skills/pipelines/character-animation/script-director.md @@ -35,3 +35,12 @@ In the `script` artifact metadata, include: - `character_beats`, - `required_emotions`, - `required_actions`. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/cinematic/asset-director.md b/skills/pipelines/cinematic/asset-director.md index 73f45a26..05a0105d 100644 --- a/skills/pipelines/cinematic/asset-director.md +++ b/skills/pipelines/cinematic/asset-director.md @@ -157,8 +157,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/cinematic/idea-director.md b/skills/pipelines/cinematic/idea-director.md index 2f0f1c6a..0f332c81 100644 --- a/skills/pipelines/cinematic/idea-director.md +++ b/skills/pipelines/cinematic/idea-director.md @@ -124,3 +124,12 @@ Record the decision in `brief.metadata.music_strategy` with the chosen source an - Assuming generated inserts are available without checking tools. - Quietly turning a motion-led brief into a still-led teaser. - Planning a trailer shape with no reveal or payoff. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/cinematic/proposal-director.md b/skills/pipelines/cinematic/proposal-director.md index 9508e164..1bbdcbd4 100644 --- a/skills/pipelines/cinematic/proposal-director.md +++ b/skills/pipelines/cinematic/proposal-director.md @@ -285,8 +285,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/cinematic/publish-director.md b/skills/pipelines/cinematic/publish-director.md index bf70f0af..b7d16725 100644 --- a/skills/pipelines/cinematic/publish-director.md +++ b/skills/pipelines/cinematic/publish-director.md @@ -54,3 +54,12 @@ Store in `publish_log.metadata`: - Mixing teaser and hero outputs without clear naming. - Writing generic metadata that ignores the mood. - Treating all cutdowns as interchangeable. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/cinematic/scene-director.md b/skills/pipelines/cinematic/scene-director.md index d03817bb..034a026d 100644 --- a/skills/pipelines/cinematic/scene-director.md +++ b/skills/pipelines/cinematic/scene-director.md @@ -76,3 +76,12 @@ Recommended metadata keys: - Using title cards as filler. - Treating generated inserts like the primary story without saying so. - Planning flashy transitions for every beat. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/cinematic/script-director.md b/skills/pipelines/cinematic/script-director.md index 4f065e40..b5e3e512 100644 --- a/skills/pipelines/cinematic/script-director.md +++ b/skills/pipelines/cinematic/script-director.md @@ -78,3 +78,12 @@ add the source. Do not invent statistics, dates, or attributions. - Writing full explanatory paragraphs instead of beats. - Using too many title cards. - Revealing the best moment too early. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/clip-factory/asset-director.md b/skills/pipelines/clip-factory/asset-director.md index 027cc605..37bd85c2 100644 --- a/skills/pipelines/clip-factory/asset-director.md +++ b/skills/pipelines/clip-factory/asset-director.md @@ -101,8 +101,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/clip-factory/idea-director.md b/skills/pipelines/clip-factory/idea-director.md index ea0fe712..7563231a 100644 --- a/skills/pipelines/clip-factory/idea-director.md +++ b/skills/pipelines/clip-factory/idea-director.md @@ -102,3 +102,12 @@ Recommended metadata keys: - Assuming every source can produce vertical clips cleanly. - Treating all clips as interchangeable instead of intentionally varied. - Starting extraction without defining what "good" means for this batch. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/clip-factory/publish-director.md b/skills/pipelines/clip-factory/publish-director.md index 0273b83c..e6a53bf4 100644 --- a/skills/pipelines/clip-factory/publish-director.md +++ b/skills/pipelines/clip-factory/publish-director.md @@ -58,3 +58,12 @@ Store in `publish_log.metadata`: - Publishing the whole batch on the same day. - Using one caption everywhere. - Losing the rank/order logic after rendering is complete. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/clip-factory/scene-director.md b/skills/pipelines/clip-factory/scene-director.md index a321385d..8549497e 100644 --- a/skills/pipelines/clip-factory/scene-director.md +++ b/skills/pipelines/clip-factory/scene-director.md @@ -74,3 +74,12 @@ Each scene should map to one clip variant or one clip family deliverable. Keep ` - Ignoring slide or screen-share content while focusing only on faces. - Letting each clip invent its own layout. - Forgetting that the first frame determines whether a viewer keeps watching. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/clip-factory/script-director.md b/skills/pipelines/clip-factory/script-director.md index fc6a16a5..20b14d18 100644 --- a/skills/pipelines/clip-factory/script-director.md +++ b/skills/pipelines/clip-factory/script-director.md @@ -99,3 +99,12 @@ add the source. Do not invent statistics, dates, or attributions. - Selecting too many calm, same-energy clips. - Preserving chronological order instead of ranking by quality. - Treating transcript quality issues as minor when they affect selection accuracy. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/documentary-montage/asset-director.md b/skills/pipelines/documentary-montage/asset-director.md index 17e58db7..edad9580 100644 --- a/skills/pipelines/documentary-montage/asset-director.md +++ b/skills/pipelines/documentary-montage/asset-director.md @@ -515,3 +515,12 @@ clip_search.execute({ Used when the edit director wants to confirm the provider/URL before locking the cut. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/documentary-montage/edit-director.md b/skills/pipelines/documentary-montage/edit-director.md index e26496ee..8468974f 100644 --- a/skills/pipelines/documentary-montage/edit-director.md +++ b/skills/pipelines/documentary-montage/edit-director.md @@ -369,3 +369,12 @@ Canonical shape for this pipeline: This gives a 90s piece with 3 breathing points (fade_in, silence, fade_out), a clear hero arc (slots 1 → 11 → 15), and no adjacent scale collisions. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/documentary-montage/idea-director.md b/skills/pipelines/documentary-montage/idea-director.md index 6b07657a..204187d2 100644 --- a/skills/pipelines/documentary-montage/idea-director.md +++ b/skills/pipelines/documentary-montage/idea-director.md @@ -211,3 +211,12 @@ open for the scene director to decide per slot. the user explicitly says no. - Skipping the end-tag because "the images speak for themselves". They don't — the end-tag is the thesis. Propose one every time. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/documentary-montage/scene-director.md b/skills/pipelines/documentary-montage/scene-director.md index 3e3905a7..9280be61 100644 --- a/skills/pipelines/documentary-montage/scene-director.md +++ b/skills/pipelines/documentary-montage/scene-director.md @@ -347,3 +347,12 @@ Each slot gets: - `target_hold_seconds` summing to ~90. This is the artifact the asset director will run retrieval against. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/explainer/asset-director.md b/skills/pipelines/explainer/asset-director.md index b98c358d..6bac4737 100644 --- a/skills/pipelines/explainer/asset-director.md +++ b/skills/pipelines/explainer/asset-director.md @@ -273,8 +273,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/explainer/idea-director.md b/skills/pipelines/explainer/idea-director.md index 6b8deb6b..f500804b 100644 --- a/skills/pipelines/explainer/idea-director.md +++ b/skills/pipelines/explainer/idea-director.md @@ -181,3 +181,12 @@ If no existing playbook fits, describe the desired style in `brief.style` and th - Angle 1: "HTTPS Explained" — generic, no hook - Angle 2: "How HTTPS Works" — same thing, reworded - Angle 3: "Understanding HTTPS" — still the same, no structural difference + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/explainer/proposal-director.md b/skills/pipelines/explainer/proposal-director.md index 526cc61c..db718b72 100644 --- a/skills/pipelines/explainer/proposal-director.md +++ b/skills/pipelines/explainer/proposal-director.md @@ -40,6 +40,7 @@ A `render_runtime_selection` decision with only one option considered when both | Tool registry | `support_envelope()` output | What's actually available right now | | Cost tracker | `tools/cost_tracker.py` | Cost estimation data | | Style playbooks | `styles/*.yaml` | Available visual styles | +| Meta skill | `skills/meta/taste-direction.md` | Design read, taste dials, reference strategy | | User input | Topic, any preferences expressed | Creative direction | ## Process @@ -175,6 +176,8 @@ Choose the structure that best fits the research findings: The existing playbooks (`clean-professional`, `flat-motion-graphics`, `minimalist-diagram`) are starting points, not destinations. Most videos should get a **custom visual identity** derived from the subject matter, audience, and tone. A video about coffee should feel warm and tactile. A video about cybersecurity should feel technical and urgent. A video about marine biology should feel deep and fluid. +Before choosing or generating a playbook, read `skills/meta/taste-direction.md` and write a compact `production_plan.taste_profile`. The taste profile records the design read, `visual_variance`, `motion_intensity`, `information_density`, reference strategy, and anti-patterns. Use it to explain why the selected playbook, `composition_mode`, and asset strategy fit the brief. + **How to design visual identity:** 1. **Start from the content.** What colors does the subject naturally evoke? What textures, materials, lighting? A video about volcanoes should feel different from a video about meditation — in colors, motion speed, typography weight, and transition style. @@ -194,6 +197,7 @@ The existing playbooks (`clean-professional`, `flat-motion-graphics`, `minimalis **Record your visual identity choices in the proposal_packet:** - `production_plan.playbook`: name of preset OR "custom" +- `production_plan.taste_profile`: design read, taste dials, reference strategy, and anti-patterns - If custom, include color choices and font choices in the concept's `visual_approach` - Include the reasoning: "Warm amber palette because the subject is coffee craftsmanship" - Log as decision: `category: "playbook_selection"` @@ -535,8 +539,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/explainer/publish-director.md b/skills/pipelines/explainer/publish-director.md index aaa2dcd6..24f9b0d7 100644 --- a/skills/pipelines/explainer/publish-director.md +++ b/skills/pipelines/explainer/publish-director.md @@ -85,41 +85,53 @@ Each chapter maps to a script section's `start_seconds`. ### Step 5: Package Export -Create the export directory structure: +Use the `export_bundle` tool (capability `publish`) to do the packaging +deterministically — pass it the final `video_path` (from `render_report`), the +`title`, and the metadata you prepared (`description`, `tags`, `hashtags`, +`chapters`, optional `subtitles_path` and `thumbnail_path`/`thumbnail_concept`). +It lays out the export directory, writes the metadata files, and returns a +schema-valid `publish_log` (`status: "exported"`) in `data["publish_log"]` that +you persist as the stage artifact. + +It produces this structure: ``` exports/ / video/ - output.mp4 # Final rendered video + output.mp4 # Final rendered video (subtitles.srt alongside if provided) metadata/ metadata.json # All SEO metadata chapters.txt # Chapter markers - description.txt # Ready-to-paste description + description.txt # Ready-to-paste description (+ chapters) tags.txt # One tag per line thumbnails/ - concept.json # Thumbnail concept (or generated image) + concept.json # Thumbnail concept (or the copied thumbnail image) ``` +`export_bundle` is a local, offline packager — it does not upload. A networked +publisher (e.g. a YouTube uploader) would be a separate `publish`-capability +provider. + ### Step 6: Build Publish Log +`export_bundle` already returns a schema-valid `publish_log` in `data["publish_log"]` — persist that directly rather than hand-building one. Do **not** add extra entry fields (the schema sets `additionalProperties: false`; only `platform`, `status`, `url`, `video_id`, `visibility`, `export_path`, `timestamp`, `metadata_used`, `error` are allowed). The shape it returns: + ```json { "version": "1.0", "entries": [ { "platform": "youtube", - "status": "draft", - "timestamp": "2024-01-15T10:30:00Z", - "metadata": { + "status": "exported", + "export_path": "projects/vector-db-explainer/exports", + "timestamp": "2026-01-15T10:30:00+00:00", + "metadata_used": { "title": "Vector Databases Explained in 60 Seconds", - "description_length": 450, - "tags_count": 8, - "chapters_count": 6, - "thumbnail_ready": false - }, - "export_path": "exports/vector-db-explainer/", - "video_path": "renders/output.mp4" + "description": "What vector databases are and when to use them.", + "hashtags": ["#ai", "#vectordb"], + "chapters": [{ "start_seconds": 0, "title": "Introduction" }] + } } ] } @@ -150,3 +162,12 @@ Validate the publish_log against the schema and persist via checkpoint. - **Description keyword stuffing**: Write for humans first, search engines second. Natural language with keywords woven in. - **Forgetting the CTA**: Every description should end with a call to action. - **Wrong platform format**: YouTube descriptions differ from TikTok captions. Tailor to the target platform. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/explainer/scene-director.md b/skills/pipelines/explainer/scene-director.md index 0d37827a..9642d666 100644 --- a/skills/pipelines/explainer/scene-director.md +++ b/skills/pipelines/explainer/scene-director.md @@ -79,7 +79,7 @@ Transform each script section into 1-3 visual scenes. Each scene is a distinct v | `text_card` | Statements, closing messages, key terms | Remotion TextCard (centered, spring animation) | 3-5s | | `animation` | Concepts needing motion (data flow, math) | Remotion, Manim | 4-10s | | `diagram` | Processes, architecture, relationships | `diagram_gen` (Mermaid), `image_selector` | 4-8s | -| `generated` | Illustrations, metaphors, real-world imagery | `image_selector` (FLUX/DALL-E) | 3-6s | +| `generated` | Illustrations, metaphors, real-world imagery | `image_selector` (FLUX/GPT Image) | 3-6s | | `talking_head` | AI avatar speaking (if HeyGen available) | HeyGen tools | 5-15s | | `broll` | Context, real-world examples | Stock or generated footage | 3-6s | | `screen_recording` | Code demos, UI walkthroughs | Recorded or simulated | 5-15s | @@ -206,7 +206,7 @@ The style playbook constrains your visual choices: **Feasibility check:** - [ ] Every `required_asset` with `source: "generate"` is achievable with available tools - [ ] Diagram descriptions are specific enough for Mermaid syntax generation -- [ ] Image descriptions are specific enough for FLUX/DALL-E prompt engineering +- [ ] Image descriptions are specific enough for FLUX/GPT Image prompt engineering - [ ] No scene requires tools that aren't in the tool registry ### Step 7: Self-Evaluate @@ -238,3 +238,12 @@ Call `handle_explainer_scene_plan(state, {"scene_plan": scene_plan_json})` to va - **Preset thinking**: A scene plan that says "make it flat-motion-graphics" is not enough. The planner must specify what makes THIS video's motion graphics feel distinct. - **Static scenes for dynamic concepts**: If the narrator describes a process or transformation, the visual should move. Use animation or progressive reveal, not a static image. - **Using `generated` type for CTA/closing screens with exact text**: AI image models hallucinate text — wrong business names, misspelled words, wrong phone numbers. Any scene with verbatim text (CTA, business info, contact details, legal) MUST be `type: "text_card"` so Remotion renders the text exactly. Never plan a `generated` image for a scene where text accuracy matters. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/explainer/script-director.md b/skills/pipelines/explainer/script-director.md index 42005743..f00404c8 100644 --- a/skills/pipelines/explainer/script-director.md +++ b/skills/pipelines/explainer/script-director.md @@ -255,3 +255,12 @@ add the source. Do not invent statistics, dates, or attributions. ] } ``` + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/hybrid/asset-director.md b/skills/pipelines/hybrid/asset-director.md index 89b7e0e5..8fe60dec 100644 --- a/skills/pipelines/hybrid/asset-director.md +++ b/skills/pipelines/hybrid/asset-director.md @@ -93,8 +93,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/hybrid/idea-director.md b/skills/pipelines/hybrid/idea-director.md index 5c3a8b54..0aa620d3 100644 --- a/skills/pipelines/hybrid/idea-director.md +++ b/skills/pipelines/hybrid/idea-director.md @@ -84,3 +84,12 @@ Recommended metadata keys: - Calling everything hybrid without defining a primary medium. - Planning support layers before understanding the source. - Treating optional generated inserts as guaranteed. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/hybrid/publish-director.md b/skills/pipelines/hybrid/publish-director.md index e8e10a6d..17e65ec8 100644 --- a/skills/pipelines/hybrid/publish-director.md +++ b/skills/pipelines/hybrid/publish-director.md @@ -48,3 +48,12 @@ Recommended metadata keys: - Hiding which output is the hero cut. - Packaging a source-led project like a generic generated asset. - Losing platform-specific copy and labeling across variants. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/hybrid/scene-director.md b/skills/pipelines/hybrid/scene-director.md index b31f5683..61182fb8 100644 --- a/skills/pipelines/hybrid/scene-director.md +++ b/skills/pipelines/hybrid/scene-director.md @@ -60,3 +60,12 @@ Recommended metadata keys: - Turning source-led scenes into overlay soup. - Forgetting variant-safe zones until compose. - Using generated inserts for every transition. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/hybrid/script-director.md b/skills/pipelines/hybrid/script-director.md index 2d6d9de7..6c1ab72f 100644 --- a/skills/pipelines/hybrid/script-director.md +++ b/skills/pipelines/hybrid/script-director.md @@ -68,3 +68,12 @@ add the source. Do not invent statistics, dates, or attributions. - Rewriting strong source dialogue into weaker narration. - Adding diagrams or cards where the footage already explains the point. - Hiding unsupported requirements until asset generation. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/localization-dub/asset-director.md b/skills/pipelines/localization-dub/asset-director.md index 637f3d44..2d6809ff 100644 --- a/skills/pipelines/localization-dub/asset-director.md +++ b/skills/pipelines/localization-dub/asset-director.md @@ -85,8 +85,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/localization-dub/idea-director.md b/skills/pipelines/localization-dub/idea-director.md index c1149fef..8c48b9a3 100644 --- a/skills/pipelines/localization-dub/idea-director.md +++ b/skills/pipelines/localization-dub/idea-director.md @@ -74,3 +74,12 @@ Recommended metadata keys: - Calling every translation request a dubbing request. - Ignoring glossary control until after audio is generated. - Promising lip sync on visually difficult source footage without warning. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/localization-dub/publish-director.md b/skills/pipelines/localization-dub/publish-director.md index bd39714a..69fbf765 100644 --- a/skills/pipelines/localization-dub/publish-director.md +++ b/skills/pipelines/localization-dub/publish-director.md @@ -42,3 +42,12 @@ If a language output has pronunciation caveats, timing warnings, or missing lip - Shipping localized videos without the matching subtitle or transcript files. - Mixing audio-dub and subtitle-only variants under the same generic filename. - Removing the QA notes that explain known issues. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/localization-dub/scene-director.md b/skills/pipelines/localization-dub/scene-director.md index 7dfdae44..11662de5 100644 --- a/skills/pipelines/localization-dub/scene-director.md +++ b/skills/pipelines/localization-dub/scene-director.md @@ -63,3 +63,12 @@ Recommended metadata keys: - Assuming dubbed audio will fit the source timing exactly. - Choosing lip sync for every shot instead of only the shots that justify it. - Forgetting about baked-in text until compose time. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/localization-dub/script-director.md b/skills/pipelines/localization-dub/script-director.md index 2a0f95f8..58223ace 100644 --- a/skills/pipelines/localization-dub/script-director.md +++ b/skills/pipelines/localization-dub/script-director.md @@ -63,3 +63,12 @@ add the source. Do not invent statistics, dates, or attributions. - Generating audio from an unreviewed transcript. - Letting product names drift across languages. - Treating translation text as final timing without acknowledging length drift. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/podcast-repurpose/asset-director.md b/skills/pipelines/podcast-repurpose/asset-director.md index 2902ad2c..07a951a3 100644 --- a/skills/pipelines/podcast-repurpose/asset-director.md +++ b/skills/pipelines/podcast-repurpose/asset-director.md @@ -98,8 +98,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/podcast-repurpose/idea-director.md b/skills/pipelines/podcast-repurpose/idea-director.md index c40c12f3..d8632304 100644 --- a/skills/pipelines/podcast-repurpose/idea-director.md +++ b/skills/pipelines/podcast-repurpose/idea-director.md @@ -90,3 +90,12 @@ Use `brief.metadata` for the richer podcast-specific contract: - Treating audio-only and video-podcast sources as the same production problem. - Planning too many deliverables from a weak episode. - Promising a rich full-episode visual treatment without the assets to support it. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/podcast-repurpose/publish-director.md b/skills/pipelines/podcast-repurpose/publish-director.md index 0323ebc9..9c99b53a 100644 --- a/skills/pipelines/podcast-repurpose/publish-director.md +++ b/skills/pipelines/podcast-repurpose/publish-director.md @@ -59,3 +59,12 @@ Recommended metadata keys: - Publishing clips without clear episode references. - Forgetting to tag or mention the guest when that audience matters. - Reusing one caption style across every platform. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/podcast-repurpose/scene-director.md b/skills/pipelines/podcast-repurpose/scene-director.md index b8671753..4190e327 100644 --- a/skills/pipelines/podcast-repurpose/scene-director.md +++ b/skills/pipelines/podcast-repurpose/scene-director.md @@ -68,3 +68,12 @@ Every layout should clearly preserve: - Planning speaker-centric layouts for audio-only episodes. - Turning every clip into the same waveform-plus-logo composition. - Using generated graphics to cover weak editorial choices. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/podcast-repurpose/script-director.md b/skills/pipelines/podcast-repurpose/script-director.md index 11673624..76c70a8e 100644 --- a/skills/pipelines/podcast-repurpose/script-director.md +++ b/skills/pipelines/podcast-repurpose/script-director.md @@ -79,3 +79,12 @@ add the source. Do not invent statistics, dates, or attributions. - Treating diarization errors as minor when they change who said the quote. - Selecting clips that need too much earlier context. - Overfitting the batch to one section of the episode. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/screen-demo/asset-director.md b/skills/pipelines/screen-demo/asset-director.md index 4f3df502..36f09cf6 100644 --- a/skills/pipelines/screen-demo/asset-director.md +++ b/skills/pipelines/screen-demo/asset-director.md @@ -159,8 +159,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/screen-demo/idea-director.md b/skills/pipelines/screen-demo/idea-director.md index 5e62c126..2ae272a6 100644 --- a/skills/pipelines/screen-demo/idea-director.md +++ b/skills/pipelines/screen-demo/idea-director.md @@ -133,3 +133,12 @@ Before checkpointing, verify: - Choosing `9:16` for a dense desktop capture just because the user asked for Shorts. - Writing a concept-heavy brief when the user really needs task completion. - Failing to note silence; if there is no voiceover, downstream stages must know immediately. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/screen-demo/publish-director.md b/skills/pipelines/screen-demo/publish-director.md index 040f540a..a59002da 100644 --- a/skills/pipelines/screen-demo/publish-director.md +++ b/skills/pipelines/screen-demo/publish-director.md @@ -78,3 +78,12 @@ For developer or product-demo content, also package: - Publishing with generic titles that omit the actual software or task. - Using the same caption for YouTube, LinkedIn, and short-form social. - Building chapter markers from the script without checking the render. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/screen-demo/scene-director.md b/skills/pipelines/screen-demo/scene-director.md index 11f5b33f..b0777c5c 100644 --- a/skills/pipelines/screen-demo/scene-director.md +++ b/skills/pipelines/screen-demo/scene-director.md @@ -129,3 +129,12 @@ If a step cannot survive vertical, say so. The correct answer is sometimes to sh - Planning vertical crops for wide UI without admitting they fail. - Adding highlight layers everywhere instead of choosing the single clearest cue. - Ignoring sensitive data revealed in seemingly minor frames. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/screen-demo/script-director.md b/skills/pipelines/screen-demo/script-director.md index fb1d6331..8c784fd2 100644 --- a/skills/pipelines/screen-demo/script-director.md +++ b/skills/pipelines/screen-demo/script-director.md @@ -125,3 +125,12 @@ add the source. Do not invent statistics, dates, or attributions. - Letting spoken timing drift away from the visual action. - Keeping builds and loading screens in real time. - Writing a silent-recording script that secretly depends on unavailable TTS. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/talking-head/asset-director.md b/skills/pipelines/talking-head/asset-director.md index c0082607..d15ad931 100644 --- a/skills/pipelines/talking-head/asset-director.md +++ b/skills/pipelines/talking-head/asset-director.md @@ -201,8 +201,17 @@ If you encounter a generation technique, provider behavior, or prompting pattern This is especially important for: - **Video generation prompting** — models respond to specific vocabularies that change with each version -- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Image model parameters** — optimal settings for FLUX, GPT Image, Imagen differ and evolve - **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices - **Remotion component patterns** — new composition techniques emerge as the framework evolves Do not rely on stale knowledge. When in doubt, search first. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/talking-head/idea-director.md b/skills/pipelines/talking-head/idea-director.md index 7003ad7e..068bb032 100644 --- a/skills/pipelines/talking-head/idea-director.md +++ b/skills/pipelines/talking-head/idea-director.md @@ -63,3 +63,12 @@ Create a brief artifact documenting: ### Step 5: Submit Validate the brief against the schema and persist via checkpoint. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/talking-head/publish-director.md b/skills/pipelines/talking-head/publish-director.md index 456dba84..3141041a 100644 --- a/skills/pipelines/talking-head/publish-director.md +++ b/skills/pipelines/talking-head/publish-director.md @@ -50,3 +50,12 @@ Document the publish event with platform, status (draft), and export path. ### Step 6: Submit Validate the publish_log against the schema and persist via checkpoint. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/talking-head/scene-director.md b/skills/pipelines/talking-head/scene-director.md index 39d1eb3e..a0ee4b6c 100644 --- a/skills/pipelines/talking-head/scene-director.md +++ b/skills/pipelines/talking-head/scene-director.md @@ -241,3 +241,12 @@ Assemble the full scene plan with: ### Step 10: Submit Validate the scene_plan against the schema and persist via checkpoint. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/skills/pipelines/talking-head/script-director.md b/skills/pipelines/talking-head/script-director.md index c98b4f58..11f35334 100644 --- a/skills/pipelines/talking-head/script-director.md +++ b/skills/pipelines/talking-head/script-director.md @@ -65,3 +65,12 @@ If you encounter uncertainty during script writing: Every factual claim in the script should be traceable to the `research_brief`. If you make a claim that isn't in the research, do additional research and add the source. Do not invent statistics, dates, or attributions. + +--- + +## Gate Reminder (Binding) + +This stage gates on human approval (`human_approval_default: true`). After review passes: +checkpoint with `status="awaiting_human"`, present the summary (the Backlot board renders +the artifact), and **END YOUR TURN**. Do not start the next stage in the same response. +Approval is per-gate — an earlier "go ahead" does not cover this gate. diff --git a/styles/premium-minimalist.yaml b/styles/premium-minimalist.yaml new file mode 100644 index 00000000..89b1013c --- /dev/null +++ b/styles/premium-minimalist.yaml @@ -0,0 +1,117 @@ +identity: + name: "Premium Minimalist" + category: minimalist + mood: calm, editorial, precise + pace: deliberate + best_for: "Investor updates, expert explainers, product narratives, high-trust launch videos" + +taste_profile: + design_read: "Premium expert explainer: calm authority, high trust, low ornament." + visual_variance: 4 + motion_intensity: 3 + information_density: 5 + palette_discipline: "Off-white field, charcoal text, restrained cobalt accent, no decorative gradients." + layout_variation: "Alternate editorial split frames, centered evidence frames, and full-bleed product/detail moments." + reference_strategy: "Create one reference still for each scene family before batch asset generation." + anti_patterns: + - "generic corporate-blue template cards" + - "decorative gradient backgrounds" + - "fast kinetic transitions that reduce comprehension" + quality_gates: + - "Every frame should feel intentionally sparse, not unfinished." + - "Motion should clarify hierarchy instead of decorating it." + +visual_language: + color_palette: + primary: ["#111827", "#374151"] + accent: ["#2563EB", "#0F766E"] + background: "#F9FAFB" + text: "#111827" + muted: "#6B7280" + composition: asymmetrical editorial grid with large margins, hard alignment, and one focal object per frame + texture: flat matte fields, fine divider lines, subtle photographic grain only when source imagery needs cohesion + +typography: + headings: + font: "Inter" + weight: 700 + tracking: "-0.01em" + body: + font: "Inter" + weight: 400 + line_height: 1.55 + code: + font: "JetBrains Mono" + weight: 400 + stat_card: + font: "Inter" + weight: 800 + size_multiplier: 3.2 + scale_system: "major_third" + weight_matrix: + title: 800 + heading: 700 + body: 400 + caption: 500 + +motion: + transitions: [fade, dissolve, slide-left] + animation_style: "restrained ease-out, precise reveal, no bounce" + pacing_rules: + min_scene_hold_seconds: 2.75 + max_scene_hold_seconds: 12 + text_card_hold_seconds: 3.75 + stat_card_hold_seconds: 3.25 + transition_duration_seconds: 0.45 + entrance: "fade-up 12px with opacity ramp" + exit: "soft fade with slight y-offset" + +audio: + voice_style: "measured, expert, warm, low hype" + music_mood: "minimal pulse, clean synth bed, quiet confidence" + music_volume: 0.07 + sfx_style: "small tactile ticks and soft paper-like swishes" + ducking_threshold_db: -4 + +asset_generation: + image_prompt_prefix: "premium minimalist editorial frame, restrained palette, precise composition, generous negative space, " + image_negative_prompt: "busy, glossy, decorative gradient, neon, cluttered, low contrast, generic corporate template" + diagram_style: "thin-line editorial diagram, charcoal text, cobalt highlight, large margins" + consistency_anchors: + - "Off-white background with charcoal typography" + - "Cobalt accent used only for hierarchy or proof points" + - "Large margins and hard alignment" + - "No decorative shapes unless they carry information" + +overlays: + stat_card: + bg: "#FFFFFF" + border: "#D1D5DB" + radius: 6 + shadow: "0 1px 6px rgba(17,24,39,0.08)" + key_term: + bg: "#EFF6FF" + text: "#1D4ED8" + radius: 4 + code_block: + bg: "#111827" + text: "#F9FAFB" + highlight: "#93C5FD" + +quality_rules: + - "Minimum contrast ratio 4.5:1 for all text" + - "No more than 2 accent colors on screen at once" + - "Keep one primary focal object or claim per frame" + - "Use motion to reveal hierarchy, not to add energy" + - "Avoid decorative gradients and oversized floating shapes" + +chart_palette: + - "#2563EB" + - "#0F766E" + - "#111827" + - "#64748B" + +color_rules: + harmony_type: "analogous" + contrast_validation: true + colorblind_safe: true diff --git a/tests/backlot/test_gate_scenarios.py b/tests/backlot/test_gate_scenarios.py new file mode 100644 index 00000000..d00920c3 --- /dev/null +++ b/tests/backlot/test_gate_scenarios.py @@ -0,0 +1,99 @@ +"""Gate-integrity scenarios for Backlot and checkpoint hardening.""" + +import json +from pathlib import Path + +import pytest + +from backlot import state as state_mod +from backlot.state import load_board_state +from lib.checkpoint import CheckpointValidationError, write_checkpoint + + +def _script_artifact() -> dict: + return { + "version": "1.0", + "title": "Gate Test", + "total_duration_seconds": 5, + "sections": [{"id": "s1", "text": "Hello.", "start_seconds": 0, "end_seconds": 5}], + } + + +def _manifest_artifact() -> dict: + return {"version": "1.0", "assets": [], "total_cost_usd": 0.0} + + +def _write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def test_completed_gated_stage_without_approval_is_rejected(tmp_path): + with pytest.raises(CheckpointValidationError, match="GATE VIOLATION"): + write_checkpoint( + tmp_path, + "film", + "script", + "completed", + {"script": _script_artifact()}, + pipeline_type="cinematic", + ) + + +def test_typo_pipeline_type_fails_closed(tmp_path): + with pytest.raises(CheckpointValidationError, match="Unknown pipeline_type"): + write_checkpoint( + tmp_path, + "film", + "script", + "completed", + {"script": _script_artifact()}, + pipeline_type="cinemtaic", + human_approved=True, + ) + + +def test_handwritten_completed_checkpoint_surfaces_gate_skip(tmp_path, monkeypatch): + monkeypatch.setattr(state_mod, "PROJECTS_DIR", tmp_path) + project = tmp_path / "film" + _write(project / "checkpoint_script.json", { + "version": "1.0", + "project_id": "film", + "pipeline_type": "cinematic", + "stage": "script", + "status": "completed", + "timestamp": "2026-07-02T00:00:00Z", + "artifacts": {"script": _script_artifact()}, + }) + + state = load_board_state(project) + + script = next(stage for stage in state["stages"] if stage["name"] == "script") + assert script["gate_skipped"] is True + + +def test_awaiting_then_approved_archives_history_without_gate_skip(tmp_path): + write_checkpoint( + tmp_path, + "film", + "assets", + "awaiting_human", + {"asset_manifest": _manifest_artifact()}, + pipeline_type="cinematic", + ) + write_checkpoint( + tmp_path, + "film", + "assets", + "completed", + {"asset_manifest": _manifest_artifact()}, + pipeline_type="cinematic", + human_approved=True, + ) + + state = load_board_state(tmp_path / "film") + + assets = next(stage for stage in state["stages"] if stage["name"] == "assets") + assert assets.get("gate_skipped") in (None, False) + assert assets["versions"] == 2 + assert assets["history_entries"][0]["status"] == "awaiting_human" diff --git a/tests/backlot/test_server.py b/tests/backlot/test_server.py new file mode 100644 index 00000000..7dfed8ed --- /dev/null +++ b/tests/backlot/test_server.py @@ -0,0 +1,205 @@ +"""Server/API tests for Backlot. + +These cover the deterministic eval surface in internal/evals/BACKLOT_EVAL_PLAN.md: +API shape, path safety, media/thumb serving, range requests, and loose +performance budgets. +""" + +from __future__ import annotations + +import io +import json +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from backlot import server as server_mod +from backlot import state as state_mod + + +@pytest.fixture +def projects_root(tmp_path, monkeypatch): + root = tmp_path / "projects" + root.mkdir() + monkeypatch.setattr(state_mod, "PROJECTS_DIR", root) + monkeypatch.setattr(server_mod, "PROJECTS_DIR", root) + monkeypatch.setattr(server_mod, "_summary_cache", {}) + monkeypatch.setattr(server_mod, "_PROJECTS_ROOT_STR", __import__("os").path.normcase(str(root.resolve()))) + monkeypatch.setattr(server_mod, "THUMB_CACHE_DIR", tmp_path / "thumbs") + return root + + +@pytest.fixture +def client(projects_root, monkeypatch): + async def no_watch(): + return None + + monkeypatch.setattr(server_mod, "_watch_projects", no_watch) + with TestClient(server_mod.create_app()) as c: + yield c + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def _make_project(root: Path, project_id: str = "film") -> Path: + project = root / project_id + (project / "artifacts").mkdir(parents=True) + (project / "assets" / "images").mkdir(parents=True) + (project / "assets" / "video").mkdir(parents=True) + (project / "renders").mkdir(parents=True) + _write_json( + project / "project.json", + { + "project_id": project_id, + "title": "Film", + "pipeline_type": "cinematic", + "created_at": "2026-07-02T00:00:00Z", + }, + ) + _write_json( + project / "checkpoint_script.json", + { + "version": "1.0", + "project_id": project_id, + "pipeline_type": "cinematic", + "stage": "script", + "status": "awaiting_human", + "timestamp": "2026-07-02T00:01:00Z", + "artifacts": {}, + }, + ) + return project + + +def _write_png(path: Path, color: tuple[int, int, int] = (200, 40, 80)) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + img = Image.new("RGB", (24, 16), color) + buf = io.BytesIO() + img.save(buf, format="PNG") + path.write_bytes(buf.getvalue()) + + +class TestBacklotServerApi: + def test_health(self, client): + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"ok": True, "app": "backlot"} + + def test_projects_shape_and_state(self, client, projects_root): + _make_project(projects_root, "film") + + projects = client.get("/api/projects") + assert projects.status_code == 200 + body = projects.json() + assert len(body) == 1 + assert body[0]["project_id"] == "film" + assert body[0]["awaiting_human"] is True + assert "stage_states" in body[0] + + state = client.get("/api/project/film/state") + assert state.status_code == 200 + state_body = state.json() + assert state_body["project_id"] == "film" + assert state_body["title"] == "Film" + assert state_body["stages"] + + @pytest.mark.parametrize( + ("url", "status"), + [ + ("/api/project/../state", 404), + ("/api/project/C:/state", 400), + ("/api/project/nope/state", 404), + ], + ) + def test_project_id_rejects_bad_or_unknown_ids(self, client, url, status): + response = client.get(url) + assert response.status_code == status + + def test_media_rejects_path_traversal(self, client, projects_root): + _make_project(projects_root, "film") + response = client.get("/media/film/%2E%2E/project.json") + assert response.status_code == 403 + + def test_media_serves_range_requests(self, client, projects_root): + project = _make_project(projects_root, "film") + media = project / "renders" / "final.mp4" + media.write_bytes(b"0123456789") + + response = client.get("/media/film/renders/final.mp4", headers={"Range": "bytes=2-5"}) + + assert response.status_code == 206 + assert response.content == b"2345" + assert response.headers["content-range"].startswith("bytes 2-5/10") + + def test_thumb_downscales_image_and_passes_through_non_media(self, client, projects_root): + project = _make_project(projects_root, "film") + _write_png(project / "assets" / "images" / "sc1.png") + text = project / "artifacts" / "note.txt" + text.write_text("hello", encoding="utf-8") + + image = client.get("/thumb/film/assets/images/sc1.png?w=320") + assert image.status_code == 200 + assert image.headers["content-type"] == "image/jpeg" + assert image.content.startswith(b"\xff\xd8") + + passthrough = client.get("/thumb/film/artifacts/note.txt") + assert passthrough.status_code == 200 + assert passthrough.content == b"hello" + + +class TestBacklotPerformanceBudgets: + def test_projects_and_state_stay_within_loose_budgets(self, client, projects_root): + for i in range(25): + project = _make_project(projects_root, f"film-{i:02d}") + _write_json( + project / "artifacts" / "scene_plan.json", + {"version": "1.0", "scenes": [{"id": "sc1", "start_seconds": 0, "end_seconds": 1}]}, + ) + + t0 = time.perf_counter() + cold = client.get("/api/projects") + cold_s = time.perf_counter() - t0 + assert cold.status_code == 200 + assert cold_s < 2.0 + + t1 = time.perf_counter() + warm = client.get("/api/projects") + warm_s = time.perf_counter() - t1 + assert warm.status_code == 200 + assert warm_s < 0.150 + + t2 = time.perf_counter() + state = client.get("/api/project/film-00/state") + state_s = time.perf_counter() - t2 + assert state.status_code == 200 + assert state_s < 0.400 + + def test_image_thumb_generation_stays_within_budget(self, client, projects_root): + project = _make_project(projects_root, "film") + _write_png(project / "assets" / "images" / "sc1.png") + + t0 = time.perf_counter() + response = client.get("/thumb/film/assets/images/sc1.png?w=640") + elapsed = time.perf_counter() - t0 + + assert response.status_code == 200 + assert elapsed < 1.5 + + +class TestFindingsFixes: + """Regression tests for dogfood findings F-03 (thumb video fallback).""" + + def test_thumb_never_serves_raw_video_bytes(self, client, projects_root): + p = _make_project(projects_root, "vid") + fake_video = p / "renders" / "final.mp4" + fake_video.parent.mkdir(parents=True, exist_ok=True) + # Not a real video: ffmpeg poster extraction will fail. + fake_video.write_bytes(b"\x00" * 4096) + res = client.get("/thumb/vid/renders/final.mp4") + assert res.status_code == 404 # never the raw video bytes (F-03) diff --git a/tests/backlot/test_state.py b/tests/backlot/test_state.py new file mode 100644 index 00000000..86fbce9b --- /dev/null +++ b/tests/backlot/test_state.py @@ -0,0 +1,353 @@ +"""Unit tests for Backlot BoardState derivation (backlot/state.py).""" + +import json +import time +from pathlib import Path + +import pytest + +from backlot import state as state_mod +from backlot.state import list_projects, load_board_state, summarize_project + + +@pytest.fixture +def projects_root(tmp_path, monkeypatch): + root = tmp_path / "projects" + root.mkdir() + monkeypatch.setattr(state_mod, "PROJECTS_DIR", root) + return root + + +def _make_project(root: Path, pid: str) -> Path: + p = root / pid + (p / "artifacts").mkdir(parents=True) + (p / "assets" / "images").mkdir(parents=True) + (p / "renders").mkdir() + return p + + +def _write(p: Path, data: dict) -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(data), encoding="utf-8") + + +SCENE_PLAN = { + "version": "1.0", + "scenes": [ + {"id": "sc1", "type": "generated", "description": "opening", + "start_seconds": 0, "end_seconds": 4, "script_section_id": "s1", + "hero_moment": False}, + {"id": "sc2", "type": "generated", "description": "climax", + "start_seconds": 4, "end_seconds": 10, "hero_moment": True}, + ], +} + +SCRIPT = { + "version": "1.0", "title": "Test Film", "total_duration_seconds": 10, + "sections": [ + {"id": "s1", "text": "It begins.", "start_seconds": 0, "end_seconds": 4}, + {"id": "s2", "text": "It ends.", "start_seconds": 4, "end_seconds": 10}, + ], +} + + +class TestBoardState: + def test_full_project(self, projects_root): + p = _make_project(projects_root, "film") + _write(p / "project.json", {"project_id": "film", "title": "My Film", + "pipeline_type": "cinematic", "created_at": "2026-01-01T00:00:00Z"}) + _write(p / "artifacts" / "scene_plan.json", SCENE_PLAN) + _write(p / "artifacts" / "script.json", SCRIPT) + img = p / "assets" / "images" / "sc1.png" + img.write_bytes(b"fake") + _write(p / "artifacts" / "asset_manifest.json", { + "version": "1.0", + "assets": [ + {"id": "a1", "type": "image", "path": "assets/images/sc1.png", + "scene_id": "sc1", "source_tool": "t", "cost_usd": 0.1}, + {"id": "a2", "type": "image", "path": "assets/images/missing.png", + "scene_id": "sc2", "source_tool": "t"}, + ], + "total_cost_usd": 0.1, + }) + _write(p / "checkpoint_script.json", { + "version": "1.0", "project_id": "film", "pipeline_type": "cinematic", + "stage": "script", "status": "completed", "timestamp": "2026-01-01T01:00:00Z", + "human_approved": True, "artifacts": {}, + }) + + s = load_board_state(p) + assert s["title"] == "My Film" + assert s["pipeline"]["pipeline_type"] == "cinematic" + assert s["pipeline"]["known"] is True + board = s["storyboard"] + assert len(board["scenes"]) == 2 + sc1, sc2 = board["scenes"] + assert sc1["narration"] == "It begins." + assert sc1["visual"]["exists"] is True + # sc2 has no script_section_id -> joined by timing overlap + assert sc2["narration"] == "It ends." + assert sc2["hero_moment"] is True + assert sc2["visual"]["exists"] is False # missing file flagged + script_stage = next(x for x in s["stages"] if x["name"] == "script") + assert script_stage["status"] == "completed" + + def test_gate_skip_detection(self, projects_root): + p = _make_project(projects_root, "sneaky") + # completed on a gated stage with no awaiting_human history and no + # human_approved -> gate_skipped flag + _write(p / "checkpoint_script.json", { + "version": "1.0", "project_id": "sneaky", "pipeline_type": "cinematic", + "stage": "script", "status": "completed", + "timestamp": "2026-01-01T01:00:00Z", "artifacts": {}, + }) + s = load_board_state(p) + script_stage = next(x for x in s["stages"] if x["name"] == "script") + assert script_stage["gate_skipped"] is True + + # with an archived awaiting_human version, the gate was honored + _write(p / "history" / "checkpoint_script_20260101.json", { + "stage": "script", "status": "awaiting_human", + }) + s2 = load_board_state(p) + script_stage2 = next(x for x in s2["stages"] if x["name"] == "script") + assert script_stage2["gate_skipped"] is False + + def test_generating_state_from_events(self, projects_root): + p = _make_project(projects_root, "live") + _write(p / "artifacts" / "scene_plan.json", SCENE_PLAN) + events = [ + {"ts": "t1", "tool": "img", "event": "start", "scene_id": "sc1"}, + {"ts": "t2", "tool": "img", "event": "finish", "scene_id": "sc1"}, + {"ts": "t3", "tool": "img", "event": "start", "scene_id": "sc2"}, + ] + (p / "events.jsonl").write_text( + "\n".join(json.dumps(e) for e in events) + "\n", encoding="utf-8") + s = load_board_state(p) + cards = {c["id"]: c for c in s["storyboard"]["scenes"]} + assert cards["sc1"]["generating"] is False + assert cards["sc2"]["generating"] is True + assert cards["sc2"]["generating_tool"] == "img" + + def test_degraded_project_never_crashes(self, projects_root): + p = projects_root / "bare" + p.mkdir() + (p / "something.mp4").write_bytes(b"x") + (p / "artifacts").mkdir() + (p / "artifacts" / "script.json").write_text("NOT JSON", encoding="utf-8") + s = load_board_state(p) + assert s["has_pipeline_state"] is False + assert s["storyboard"] is None + assert s["media"]["renders"][0]["path"] == "something.mp4" + assert s["media"]["renders"][0]["at_root"] is True + + def test_undeclared_stage_surfaces(self, projects_root): + p = _make_project(projects_root, "legacy") + _write(p / "checkpoint_idea.json", { + "version": "1.0", "project_id": "legacy", "pipeline_type": "cinematic", + "stage": "idea", "status": "completed", + "timestamp": "2026-01-01T01:00:00Z", "artifacts": {}, + }) + s = load_board_state(p) + idea = next(x for x in s["stages"] if x["name"] == "idea") + assert idea.get("undeclared") is True + + +class TestLibrary: + def test_list_projects_sorts_live_first(self, projects_root): + old = _make_project(projects_root, "old-film") + _write(old / "checkpoint_script.json", {"stage": "script", "status": "completed"}) + # backdate everything in old-film + import os + past = time.time() - 60 * 60 * 24 * 30 + for f in old.rglob("*"): + if f.is_file(): + os.utime(f, (past, past)) + + fresh = _make_project(projects_root, "fresh-film") + _write(fresh / "checkpoint_script.json", {"stage": "script", "status": "in_progress"}) + + projects = list_projects(projects_root) + assert [p["project_id"] for p in projects][0] == "fresh-film" + assert projects[0]["live"] is True + assert projects[1]["live"] is False + + def test_underscore_dirs_skipped(self, projects_root): + (projects_root / "_analysis").mkdir() + _make_project(projects_root, "real") + ids = [p["project_id"] for p in list_projects(projects_root)] + assert ids == ["real"] + + def test_summary_shape(self, projects_root): + p = _make_project(projects_root, "sum") + _write(p / "project.json", {"title": "Sum", "pipeline_type": "cinematic"}) + _write(p / "checkpoint_script.json", { + "stage": "script", "status": "awaiting_human", + "timestamp": "2026-01-01T01:00:00Z", "artifacts": {}, + }) + summary = summarize_project(p) + assert summary["awaiting_human"] is True + assert summary["active_stage"] == "script" + + +class TestFindingsFixes: + """Regression tests for dogfood findings F-04/F-05.""" + + def test_artifact_refs_outside_project_are_not_followed(self, projects_root, tmp_path): + # F-04: a checkpoint pointing at JSON outside the project tree + # must not surface that file on the board. + secret = tmp_path / "secret.json" + secret.write_text(json.dumps({"version": "1.0", "leaked": True}), encoding="utf-8") + p = _make_project(projects_root, "sneaky-ref") + _write(p / "checkpoint_script.json", { + "stage": "script", "status": "completed", + "timestamp": "2026-01-01T01:00:00Z", + "artifacts": {"script": str(secret)}, + }) + s = load_board_state(p) + assert "script" not in s["artifacts"] + + def test_inside_project_absolute_refs_still_resolve(self, projects_root): + p = _make_project(projects_root, "abs-ref") + _write(p / "artifacts" / "inline_script.json", SCRIPT) + _write(p / "checkpoint_script.json", { + "stage": "script", "status": "completed", + "timestamp": "2026-01-01T01:00:00Z", + "artifacts": {"script": str((p / "artifacts" / "inline_script.json").resolve())}, + }) + s = load_board_state(p) + assert s["artifacts"]["script"]["title"] == "Test Film" + + def test_stalled_in_progress_stage_flagged(self, projects_root): + # F-05: an in_progress stage with no recent activity reads stalled. + import os + p = _make_project(projects_root, "wedged") + _write(p / "checkpoint_research.json", { + "stage": "research", "status": "in_progress", + "timestamp": "2026-01-01T01:00:00Z", "artifacts": {}, + }) + past = time.time() - 30 * 60 + for f in p.rglob("*"): + if f.is_file(): + os.utime(f, (past, past)) + s = load_board_state(p) + research = next(x for x in s["stages"] if x["name"] == "research") + assert research["stalled"] is True + assert research["stalled_minutes"] >= 29 + + def test_fresh_in_progress_not_stalled(self, projects_root): + p = _make_project(projects_root, "busy") + _write(p / "checkpoint_research.json", { + "stage": "research", "status": "in_progress", + "timestamp": "2026-01-01T01:00:00Z", "artifacts": {}, + }) + s = load_board_state(p) + research = next(x for x in s["stages"] if x["name"] == "research") + assert "stalled" not in research + + +class TestStoryboardVisualSelection: + """The renderable / snapshot / takes logic in _build_storyboard. + + Covers the atelier-thumbnail work: a .tsx composition asset is not a + showable visual; a missing raster file still surfaces as an indicator; + an existing SVG diagram IS showable; snapshots/.png is the fallback. + """ + + def _project_with_scenes(self, root, scenes, assets): + p = _make_project(root, "vis") + _write(p / "project.json", {"pipeline_type": "cinematic"}) + _write(p / "artifacts" / "scene_plan.json", {"version": "1.0", "scenes": scenes}) + _write(p / "artifacts" / "asset_manifest.json", {"version": "1.0", "assets": assets}) + return p + + def _card(self, p, scene_id): + s = load_board_state(p) + return next(c for c in s["storyboard"]["scenes"] if c["id"] == scene_id) + + def test_existing_tsx_animation_is_not_a_visual(self, projects_root): + # A bespoke composition asset exists on disk but can't be shown. + p = self._project_with_scenes( + projects_root, + [{"id": "sc1", "type": "animation", "description": "morph", + "start_seconds": 0, "end_seconds": 5}], + [{"id": "a1", "type": "animation", "path": "Composition.tsx", "scene_id": "sc1", + "source_tool": "atelier_remotion"}], + ) + (p / "Composition.tsx").write_text("export const X = 1;", encoding="utf-8") + card = self._card(p, "sc1") + # No snapshot yet -> no renderable visual, falls to placeholder (None). + assert card["visual"] is None + assert card["takes"] == [] + + def test_snapshot_is_the_fallback_for_animation_scene(self, projects_root): + p = self._project_with_scenes( + projects_root, + [{"id": "sc1", "type": "animation", "description": "morph", + "start_seconds": 0, "end_seconds": 5}], + [{"id": "a1", "type": "animation", "path": "Composition.tsx", "scene_id": "sc1", + "source_tool": "atelier_remotion"}], + ) + (p / "Composition.tsx").write_text("x", encoding="utf-8") + (p / "snapshots").mkdir() + (p / "snapshots" / "sc1.png").write_bytes(b"\x89PNG") + card = self._card(p, "sc1") + assert card["visual"] is not None + assert card["visual"]["snapshot"] is True + assert card["visual"]["renderable"] is True + assert card["visual"]["path"].endswith("sc1.png") + + def test_snapshot_matches_id_underscore_suffix(self, projects_root): + p = self._project_with_scenes( + projects_root, + [{"id": "sc1", "type": "animation", "start_seconds": 0, "end_seconds": 5}], + [], + ) + (p / "snapshots").mkdir() + (p / "snapshots" / "sc1_hero.png").write_bytes(b"\x89PNG") + card = self._card(p, "sc1") + assert card["visual"] is not None and card["visual"]["snapshot"] is True + + def test_existing_svg_diagram_is_renderable(self, projects_root): + # Regression guard: an existing non-raster-but-showable image (.svg) + # must remain a visual, not be dropped to a placeholder. + p = self._project_with_scenes( + projects_root, + [{"id": "sc1", "type": "diagram", "start_seconds": 0, "end_seconds": 5}], + [{"id": "a1", "type": "diagram", "path": "assets/images/d.svg", "scene_id": "sc1", + "source_tool": "diagram_gen"}], + ) + (p / "assets" / "images" / "d.svg").write_text("", encoding="utf-8") + card = self._card(p, "sc1") + assert card["visual"] is not None + assert card["visual"]["exists"] is True + assert card["visual"]["renderable"] is True + + def test_missing_raster_file_still_flagged(self, projects_root): + # The "asset in manifest, file missing" indicator must survive. + p = self._project_with_scenes( + projects_root, + [{"id": "sc1", "type": "generated", "start_seconds": 0, "end_seconds": 5}], + [{"id": "a1", "type": "image", "path": "assets/images/gone.png", "scene_id": "sc1", + "source_tool": "t"}], + ) + card = self._card(p, "sc1") + assert card["visual"] is not None + assert card["visual"]["exists"] is False + + def test_renderable_prefers_existing_and_takes_exclude_missing(self, projects_root): + # Two takes: one real png, one missing. Active = the real one; + # takes carries only renderable (showable) entries. + p = self._project_with_scenes( + projects_root, + [{"id": "sc1", "type": "generated", "start_seconds": 0, "end_seconds": 5}], + [ + {"id": "a1", "type": "image", "path": "assets/images/real.png", "scene_id": "sc1", "source_tool": "t"}, + {"id": "a2", "type": "image", "path": "assets/images/missing.png", "scene_id": "sc1", "source_tool": "t"}, + ], + ) + (p / "assets" / "images" / "real.png").write_bytes(b"\x89PNG") + card = self._card(p, "sc1") + assert card["visual"]["exists"] is True + assert card["visual"]["path"].endswith("real.png") + assert [t["path"].split("/")[-1] for t in card["takes"]] == ["real.png"] diff --git a/tests/backlot/test_ui_bug_bash.py b/tests/backlot/test_ui_bug_bash.py new file mode 100644 index 00000000..16ead774 --- /dev/null +++ b/tests/backlot/test_ui_bug_bash.py @@ -0,0 +1,110 @@ +"""Browser regressions from the Backlot UI bug bash.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +import urllib.request + +import pytest + +from scripts import backlot_screenshot_stage + + +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import sync_playwright # noqa: E402 + + +@pytest.fixture(scope="module") +def staged_backlot_server(): + backlot_screenshot_stage.build_stage() + port = 4897 + env = dict(os.environ) + env["OPENMONTAGE_PROJECTS_DIR"] = str(backlot_screenshot_stage.STAGE_DIR) + server = subprocess.Popen( + [sys.executable, "-m", "backlot", "serve", "--port", str(port)], + cwd=backlot_screenshot_stage.REPO_ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.time() + 20 + while time.time() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1): + break + except Exception: + time.sleep(0.2) + else: + server.terminate() + raise RuntimeError("Backlot server did not become healthy") + + try: + yield f"http://127.0.0.1:{port}" + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + + +def test_project_pages_fit_mobile_and_tablet_widths(staged_backlot_server): + project_paths = [ + "/p/signal-in-the-static?static=1", + "/p/the-slow-orchard?static=1", + "/p/the-last-lighthouse?static=1", + "/p/paper-boats?static=1", + ] + viewports = [ + {"width": 390, "height": 844}, + {"width": 768, "height": 1024}, + ] + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + try: + for viewport in viewports: + page.set_viewport_size(viewport) + for path in project_paths: + page.goto(staged_backlot_server + path, wait_until="networkidle") + page.wait_for_timeout(300) + sizes = page.evaluate( + """() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth + })""" + ) + assert sizes["scrollWidth"] <= sizes["clientWidth"], ( + path, + viewport, + sizes, + ) + finally: + browser.close() + + +def test_static_navigation_invalid_route_and_active_takes(staged_backlot_server): + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1560, "height": 1000}) + try: + page.goto(staged_backlot_server + "/?static=1", wait_until="networkidle") + href = page.locator("a.lib-card").first.get_attribute("href") + assert href and "static=1" in href + + response = page.goto( + staged_backlot_server + "/p/..%2FAGENT_GUIDE.md?static=1", + wait_until="networkidle", + ) + assert response and response.status == 200 + assert "PROJECT NOT FOUND" in page.locator("body").inner_text() + + page.goto(staged_backlot_server + "/p/the-last-lighthouse?static=1", wait_until="networkidle") + page.wait_for_timeout(300) + assert page.locator(".takes .tk.active").count() >= 1 + finally: + browser.close() diff --git a/tests/backlot/test_visual_eval.py b/tests/backlot/test_visual_eval.py new file mode 100644 index 00000000..70d376ac --- /dev/null +++ b/tests/backlot/test_visual_eval.py @@ -0,0 +1,43 @@ +"""Tests for Backlot visual eval image comparison helpers.""" + +from pathlib import Path + +from PIL import Image + +from scripts.backlot_visual_eval import compare_images + + +def _img(path: Path, color: tuple[int, int, int]) -> None: + Image.new("RGB", (10, 10), color).save(path) + + +def test_compare_images_detects_large_drift(tmp_path): + expected = tmp_path / "expected.png" + actual = tmp_path / "actual.png" + diff = tmp_path / "diff.png" + _img(expected, (0, 0, 0)) + _img(actual, (255, 255, 255)) + + result = compare_images(expected, actual, diff, threshold=0.015) + + assert result["passed"] is False + assert result["changed_ratio"] == 1.0 + assert diff.exists() + + +def test_compare_images_can_mask_regions(tmp_path): + expected = tmp_path / "expected.png" + actual = tmp_path / "actual.png" + diff = tmp_path / "diff.png" + _img(expected, (0, 0, 0)) + _img(actual, (0, 0, 0)) + img = Image.open(actual) + for x in range(5): + for y in range(5): + img.putpixel((x, y), (255, 255, 255)) + img.save(actual) + + result = compare_images(expected, actual, diff, threshold=0.015, masks=[(0, 0, 5, 5)]) + + assert result["passed"] is True + assert result["changed_ratio"] == 0.0 diff --git a/tests/backlot/test_watch_captures.py b/tests/backlot/test_watch_captures.py new file mode 100644 index 00000000..bcc2487a --- /dev/null +++ b/tests/backlot/test_watch_captures.py @@ -0,0 +1,47 @@ +"""Tests for the Backlot dogfood screenshot watcher helpers.""" + +from scripts.backlot_watch_captures import capture_slug, state_fingerprint + + +def test_capture_slug_keeps_names_filesystem_safe(): + assert capture_slug("why-cities-glow", "scene_plan", "awaiting_human") == ( + "why-cities-glow-scene_plan-awaiting_human" + ) + assert capture_slug("../bad id", "C:\\stage", "in progress!") == "bad-id-C-stage-in-progress" + + +def test_state_fingerprint_changes_on_board_relevant_state_only(): + state = { + "stages": [ + {"name": "script", "status": "completed", "partial_progress": None}, + {"name": "assets", "status": "in_progress", "partial_progress": {"done": ["sc1"]}}, + ], + "storyboard": { + "scenes": [ + { + "id": "sc1", + "generating": False, + "visual": {"path": "assets/images/sc1.png", "exists": True}, + "takes": [{"path": "assets/images/sc1.png"}], + }, + {"id": "sc2", "generating": True, "generating_tool": "flux_image", "visual": None}, + ] + }, + "cost": {"total_spent_usd": 0.1}, + "media": {"renders": []}, + "events": [{"event": "start", "tool": "flux_image"}], + "last_activity": 123, + } + same = dict(state) + same["last_activity"] = 999 + + changed = dict(state) + changed["storyboard"] = { + "scenes": [ + state["storyboard"]["scenes"][0], + {"id": "sc2", "generating": False, "visual": {"path": "assets/images/sc2.png", "exists": True}}, + ] + } + + assert state_fingerprint(state) == state_fingerprint(same) + assert state_fingerprint(state) != state_fingerprint(changed) diff --git a/tests/contracts/test_backlot_contract.py b/tests/contracts/test_backlot_contract.py new file mode 100644 index 00000000..50245baa --- /dev/null +++ b/tests/contracts/test_backlot_contract.py @@ -0,0 +1,218 @@ +"""Contract tests for Backlot Phase 0: gate enforcement, checkpoint history, +project markers, and tool-event instrumentation.""" + +import json + +import pytest + +from lib.checkpoint import ( + CheckpointValidationError, + HISTORY_DIRNAME, + PROJECT_MARKER_FILENAME, + init_project, + read_checkpoint, + write_checkpoint, +) +from lib.events import emit_event, infer_project_dir, read_events + + +def _minimal_script() -> dict: + return { + "version": "1.0", + "title": "Test Script", + "total_duration_seconds": 10, + "sections": [ + {"id": "s1", "text": "Hello.", "start_seconds": 0, "end_seconds": 10} + ], + } + + +class TestGateEnforcement: + """GI-4: gated stages cannot be completed without approval evidence.""" + + def test_completed_without_approval_raises(self, tmp_path): + with pytest.raises(CheckpointValidationError, match="GATE VIOLATION"): + write_checkpoint( + tmp_path, "proj", "script", "completed", + artifacts={"script": _minimal_script()}, + pipeline_type="animated-explainer", + ) + + def test_awaiting_human_is_the_correct_gate_state(self, tmp_path): + path = write_checkpoint( + tmp_path, "proj", "script", "awaiting_human", + artifacts={"script": _minimal_script()}, + pipeline_type="animated-explainer", + ) + cp = json.loads(path.read_text()) + assert cp["status"] == "awaiting_human" + # Manifest gating is reflected in the checkpoint even when the + # caller didn't pass human_approval_required. + assert cp["human_approval_required"] is True + + def test_completed_with_approval_passes(self, tmp_path): + path = write_checkpoint( + tmp_path, "proj", "script", "completed", + artifacts={"script": _minimal_script()}, + pipeline_type="animated-explainer", + human_approved=True, + ) + assert path.exists() + + def test_assets_stage_now_gates(self, tmp_path): + """The assets gate flip: every pipeline's assets stage requires approval.""" + manifest_assets = {"version": "1.0", "assets": [], "total_cost_usd": 0.0} + with pytest.raises(CheckpointValidationError, match="GATE VIOLATION"): + write_checkpoint( + tmp_path, "proj", "assets", "completed", + artifacts={"asset_manifest": manifest_assets}, + pipeline_type="cinematic", + ) + + def test_ungated_stage_unaffected(self, tmp_path): + from tests.contracts.test_phase0_contracts import sample_artifact + + path = write_checkpoint( + tmp_path, "proj", "research", "completed", + artifacts={"research_brief": sample_artifact("research_brief")}, + pipeline_type="animated-explainer", + ) + assert path.exists() + + +class TestCheckpointHistory: + """Superseded checkpoints are archived, not destroyed.""" + + def test_overwrite_archives_previous(self, tmp_path): + write_checkpoint( + tmp_path, "proj", "script", "awaiting_human", + artifacts={"script": _minimal_script()}, + pipeline_type="animated-explainer", + ) + write_checkpoint( + tmp_path, "proj", "script", "completed", + artifacts={"script": _minimal_script()}, + pipeline_type="animated-explainer", + human_approved=True, + ) + history = list((tmp_path / "proj" / HISTORY_DIRNAME).glob("checkpoint_script_*.json")) + assert len(history) == 1 + archived = json.loads(history[0].read_text()) + assert archived["status"] == "awaiting_human" + current = read_checkpoint(tmp_path, "proj", "script") + assert current["status"] == "completed" + + def test_in_progress_refreshes_are_not_archived(self, tmp_path): + for _ in range(3): + write_checkpoint( + tmp_path, "proj", "assets", "in_progress", + artifacts={}, + pipeline_type="cinematic", + metadata={"partial_progress": {"completed_scene_ids": ["sc1"]}}, + ) + history_dir = tmp_path / "proj" / HISTORY_DIRNAME + assert not history_dir.exists() or not list(history_dir.iterdir()) + + +class TestInitProject: + def test_creates_layout_and_marker(self, tmp_path): + pdir = init_project( + "my-film", title="My Film", pipeline_type="cinematic", + pipeline_dir=tmp_path, style_playbook="clean-professional", + ) + assert (pdir / "artifacts").is_dir() + assert (pdir / "assets" / "images").is_dir() + assert (pdir / "renders").is_dir() + marker = json.loads((pdir / PROJECT_MARKER_FILENAME).read_text()) + assert marker["project_id"] == "my-film" + assert marker["pipeline_type"] == "cinematic" + assert marker["style_playbook"] == "clean-professional" + assert "created_at" in marker + + def test_idempotent_preserves_created_at(self, tmp_path): + pdir = init_project("p", title="P", pipeline_type="cinematic", pipeline_dir=tmp_path) + created = json.loads((pdir / PROJECT_MARKER_FILENAME).read_text())["created_at"] + init_project("p", title="P2", pipeline_type="cinematic", pipeline_dir=tmp_path) + marker = json.loads((pdir / PROJECT_MARKER_FILENAME).read_text()) + assert marker["created_at"] == created + assert marker["title"] == "P2" + + +class TestEvents: + def test_emit_and_read_roundtrip(self, tmp_path): + emit_event(tmp_path, {"tool": "t1", "event": "start", "scene_id": "sc1"}) + emit_event(tmp_path, {"tool": "t1", "event": "finish", "duration_s": 1.2}) + events = read_events(tmp_path) + assert len(events) == 2 + assert events[0]["event"] == "start" + assert events[1]["duration_s"] == 1.2 + assert all("ts" in e for e in events) + + def test_read_tolerates_garbage_lines(self, tmp_path): + (tmp_path / "events.jsonl").write_text('{"ok": 1}\nnot json\n{"ok": 2}\n') + events = read_events(tmp_path) + assert [e["ok"] for e in events] == [1, 2] + + def test_infer_project_dir_from_output_path(self): + from lib.events import PROJECTS_DIR + target = PROJECTS_DIR / "some-proj" / "assets" / "images" / "x.png" + assert infer_project_dir({"output_path": str(target)}) == PROJECTS_DIR / "some-proj" + assert infer_project_dir({"output_path": "C:/elsewhere/x.png"}) is None + assert infer_project_dir("not-a-dict") is None + + +class TestBaseToolInstrumentation: + def test_execute_emits_events(self, tmp_path, monkeypatch): + import lib.events as events_mod + monkeypatch.setattr(events_mod, "PROJECTS_DIR", tmp_path) + + from tools.base_tool import BaseTool, ToolResult + + class FakeTool(BaseTool): + name = "fake_tool" + + def execute(self, inputs): + return ToolResult(success=True, cost_usd=0.05) + + project = tmp_path / "proj-x" + project.mkdir() + out = project / "assets" / "clip.mp4" + FakeTool().execute({"output_path": str(out), "scene_id": "sc3"}) + + events = read_events(project) + assert [e["event"] for e in events] == ["start", "finish"] + assert events[0]["scene_id"] == "sc3" + assert events[1]["success"] is True + assert events[1]["cost_usd"] == 0.05 + + def test_execute_emits_error_event_and_reraises(self, tmp_path, monkeypatch): + import lib.events as events_mod + monkeypatch.setattr(events_mod, "PROJECTS_DIR", tmp_path) + + from tools.base_tool import BaseTool + + class BoomTool(BaseTool): + name = "boom_tool" + + def execute(self, inputs): + raise RuntimeError("kaput") + + project = tmp_path / "proj-y" + project.mkdir() + with pytest.raises(RuntimeError, match="kaput"): + BoomTool().execute({"output_path": str(project / "a.png")}) + events = read_events(project) + assert [e["event"] for e in events] == ["start", "error"] + assert "kaput" in events[1]["error"] + + def test_unattributable_call_emits_nothing_and_works(self, tmp_path): + from tools.base_tool import BaseTool, ToolResult + + class PlainTool(BaseTool): + name = "plain_tool" + + def execute(self, inputs): + return ToolResult(success=True) + + result = PlainTool().execute({"text": "hello"}) + assert result.success is True diff --git a/tests/contracts/test_dashscope_tools.py b/tests/contracts/test_dashscope_tools.py new file mode 100644 index 00000000..d9c001b8 --- /dev/null +++ b/tests/contracts/test_dashscope_tools.py @@ -0,0 +1,709 @@ +"""Contract tests for DashScope (Alibaba Cloud Bailian) provider tools. + +These tests verify that the tools satisfy the BaseTool contract without +requiring a real DashScope API key or making any API calls. They check +class attributes, schemas, status reporting, cost estimates, and the +Layer 3 skill file existence. + +Run: pytest tests/contracts/test_dashscope_tools.py -v +""" + +from pathlib import Path + +import pytest + +from tools.base_tool import ( + BaseTool, + ExecutionMode, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) +from tools.graphics.dashscope_image import DashscopeImage +from tools.audio.dashscope_tts import DashscopeTTS +from tools.analysis.dashscope_asr import DashscopeAsr + +TOOLS = [DashscopeImage, DashscopeTTS, DashscopeAsr] +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + +EXPECTED_TIER = { + DashscopeImage: ToolTier.GENERATE, + DashscopeTTS: ToolTier.VOICE, + DashscopeAsr: ToolTier.ANALYZE, +} +EXPECTED_CAPABILITY = { + DashscopeImage: "image_generation", + DashscopeTTS: "tts", + DashscopeAsr: "analysis", +} +EXPECTED_EXECUTION_MODE = { + DashscopeImage: ExecutionMode.SYNC, + DashscopeTTS: ExecutionMode.SYNC, + DashscopeAsr: ExecutionMode.ASYNC, +} + + +# ------------------------------------------------------------------ +# Contract compliance (parametrized over all 3 tools) +# ------------------------------------------------------------------ + +@pytest.mark.parametrize("cls", TOOLS, ids=lambda c: c.name) +class TestContract: + + def test_inherits_base_tool(self, cls): + assert issubclass(cls, BaseTool) + + def test_has_required_identity(self, cls): + tool = cls() + assert tool.name + assert tool.version + assert tool.provider == "dashscope" + assert tool.capability == EXPECTED_CAPABILITY[cls] + assert tool.tier == EXPECTED_TIER[cls] + assert tool.stability == ToolStability.EXPERIMENTAL + assert tool.runtime == ToolRuntime.API + + def test_has_input_schema(self, cls): + tool = cls() + schema = tool.input_schema + assert schema.get("type") == "object" + props = schema.get("properties", {}) + required = schema.get("required", []) + # Each tool has at least one required field + assert len(required) >= 1 + for field in required: + assert field in props + + def test_has_capabilities(self, cls): + tool = cls() + assert len(tool.capabilities) > 0 + + def test_has_agent_skills(self, cls): + tool = cls() + assert tool.agent_skills + assert "dashscope" in tool.agent_skills + + def test_dashscope_layer3_skill_exists(self, cls): + skill_path = ( + PROJECT_ROOT / ".agents" / "skills" / "dashscope" / "SKILL.md" + ) + assert skill_path.exists(), f"Missing Layer 3 skill: {skill_path}" + content = skill_path.read_text(encoding="utf-8") + assert "DASHSCOPE_API_KEY" in content + + def test_has_fallbacks(self, cls): + tool = cls() + assert tool.fallback or tool.fallback_tools + + def test_has_install_instructions(self, cls): + tool = cls() + assert tool.install_instructions + assert "DASHSCOPE_API_KEY" in tool.install_instructions + + def test_get_info_returns_dict(self, cls): + tool = cls() + info = tool.get_info() + assert isinstance(info, dict) + assert info["name"] == tool.name + assert info["provider"] == "dashscope" + assert info["runtime"] == "api" + assert info["agent_skills"] == ["dashscope"] + + def test_execution_mode(self, cls): + tool = cls() + assert tool.execution_mode == EXPECTED_EXECUTION_MODE[cls] + + def test_status_unavailable_without_key(self, cls, monkeypatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + tool = cls() + assert tool.get_status() == ToolStatus.UNAVAILABLE + + def test_status_available_with_key(self, cls, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "fake-key-for-testing") + tool = cls() + assert tool.get_status() == ToolStatus.AVAILABLE + + def test_idempotency_key_fields(self, cls): + tool = cls() + assert len(tool.idempotency_key_fields) > 0 + + def test_has_resource_profile(self, cls): + tool = cls() + assert tool.resource_profile.network_required is True + assert tool.resource_profile.vram_mb == 0 + + def test_has_retry_policy(self, cls): + tool = cls() + assert tool.retry_policy.max_retries >= 0 + + def test_has_side_effects(self, cls): + tool = cls() + assert len(tool.side_effects) > 0 + # Must mention it calls the API + assert any("API" in s for s in tool.side_effects) + + def test_has_user_visible_verification(self, cls): + tool = cls() + assert len(tool.user_visible_verification) > 0 + + def test_lazy_imports_requests(self, cls): + """Tool module must not import requests at top level (registry + discovery must stay fast).""" + import importlib + import sys + # Remove requests from cache to simulate fresh import + mod_name = cls.__module__ + if "requests" in sys.modules: + del sys.modules["requests"] + # Re-import the tool module — should not pull in requests + # (requests is imported inside execute(), not at module level) + importlib.reload(sys.modules[mod_name]) + # The tool module itself should not have imported requests + # (it's inside execute, so module-level reload shouldn't trigger it) + # This is a smoke test — the real proof is that registry.discover() + # works without requests installed, but requests IS installed here. + + def test_estimate_cost_returns_float(self, cls): + tool = cls() + # Use tool-specific minimal inputs + if cls is DashscopeImage: + cost = tool.estimate_cost({"prompt": "test", "n": 1}) + elif cls is DashscopeTTS: + cost = tool.estimate_cost({"text": "test"}) + else: + cost = tool.estimate_cost({"audio_url": "https://x.com/a.mp3"}) + assert isinstance(cost, float) + assert cost >= 0.0 + + def test_dry_run_returns_dict(self, cls): + tool = cls() + if cls is DashscopeImage: + result = tool.dry_run({"prompt": "test"}) + elif cls is DashscopeTTS: + result = tool.dry_run({"text": "test"}) + else: + result = tool.dry_run({"audio_url": "https://x.com/a.mp3"}) + assert isinstance(result, dict) + assert "tool" in result + assert result["tool"] == tool.name + + +# ------------------------------------------------------------------ +# Image-specific tests +# ------------------------------------------------------------------ + +class TestDashscopeImageSpecific: + + def test_default_model_is_qwen_image_2_pro(self): + tool = DashscopeImage() + assert tool.input_schema["properties"]["model"]["default"] == "qwen-image-2.0-pro" + + def test_default_size_uses_asterisk_format(self): + """CRITICAL: DashScope uses W*H (asterisk), not WxH.""" + tool = DashscopeImage() + size_default = tool.input_schema["properties"]["size"]["default"] + assert "*" in size_default + assert "x" not in size_default.lower() + + def test_cost_positive_for_image(self): + tool = DashscopeImage() + assert tool.estimate_cost({"prompt": "test", "n": 1}) > 0.0 + + def test_cost_scales_with_n(self): + tool = DashscopeImage() + cost1 = tool.estimate_cost({"prompt": "test", "n": 1}) + cost3 = tool.estimate_cost({"prompt": "test", "n": 3}) + assert cost3 > cost1 + + def test_build_payload_uses_asterisk_size(self): + tool = DashscopeImage() + payload = tool._build_payload({"prompt": "test"}) + assert "*" in payload["parameters"]["size"] + + def test_build_payload_includes_messages_structure(self): + tool = DashscopeImage() + payload = tool._build_payload({"prompt": "a cat"}) + assert "input" in payload + assert "messages" in payload["input"] + assert payload["input"]["messages"][0]["content"][0]["text"] == "a cat" + + def test_build_payload_optional_negative_prompt(self): + tool = DashscopeImage() + payload = tool._build_payload({ + "prompt": "test", + "negative_prompt": "blurry", + }) + assert payload["parameters"]["negative_prompt"] == "blurry" + + def test_build_payload_omits_negative_prompt_when_absent(self): + tool = DashscopeImage() + payload = tool._build_payload({"prompt": "test"}) + assert "negative_prompt" not in payload["parameters"] + + def test_safe_error_redacts_key(self, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "secret-key-12345") + redacted = DashscopeImage._safe_error( + Exception("failed with key secret-key-12345") + ) + assert "secret-key-12345" not in redacted + assert "[redacted]" in redacted + + +# ------------------------------------------------------------------ +# PR review regressions: multi-image download + idempotency keys +# ------------------------------------------------------------------ + +class TestDashscopeImageMultiOutput: + """Regression tests for PR #240 review: the tool advertised + multiple_outputs and accepted n>1 but only downloaded the first image. + Verify every returned URL is saved and returned as an artifact.""" + + def test_extract_image_urls_across_choices(self): + data = { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": [{"image": "https://x/1.png"}]}}, + {"finish_reason": "stop", "message": {"content": [{"image": "https://x/2.png"}]}}, + {"finish_reason": "stop", "message": {"content": [{"image": "https://x/3.png"}]}}, + ] + } + } + assert DashscopeImage._extract_image_urls(data) == [ + "https://x/1.png", + "https://x/2.png", + "https://x/3.png", + ] + + def test_extract_image_urls_within_single_choice(self): + data = { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": [ + {"image": "https://x/1.png"}, + {"image": "https://x/2.png"}, + ]}} + ] + } + } + assert DashscopeImage._extract_image_urls(data) == [ + "https://x/1.png", + "https://x/2.png", + ] + + def test_extract_image_urls_empty_when_no_images(self): + assert DashscopeImage._extract_image_urls({}) == [] + assert DashscopeImage._extract_image_urls( + {"output": {"choices": []}} + ) == [] + assert DashscopeImage._extract_image_urls( + {"output": {"choices": [{"message": {"content": [{"text": "x"}]}}]}} + ) == [] + + def test_extract_image_urls_skips_failed_choices(self): + """Per Qwen Cloud docs, a multi-output task can be SUCCEEDED with + partial failures. Choices with finish_reason != "stop" must be + skipped so we don't download partial/empty results. The failed + choice here carries a non-empty URL to prove it is the + finish_reason filter (not the truthy-url check) that skips it.""" + data = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": [{"image": "https://x/ok.png"}]}, + }, + { + "finish_reason": "content_filter", + "message": {"content": [{"image": "https://x/blocked.png"}]}, + }, + ] + } + } + assert DashscopeImage._extract_image_urls(data) == ["https://x/ok.png"] + + def test_resolve_output_paths_single_unchanged(self): + paths = DashscopeImage._resolve_output_paths("foo.png", 1) + assert paths == [Path("foo.png")] + + def test_resolve_output_paths_multiple_inserts_index(self): + paths = DashscopeImage._resolve_output_paths("foo.png", 3) + assert paths == [ + Path("foo_1.png"), + Path("foo_2.png"), + Path("foo_3.png"), + ] + + def test_resolve_output_paths_multiple_without_extension(self): + paths = DashscopeImage._resolve_output_paths("foo", 2) + assert paths == [Path("foo_1"), Path("foo_2")] + + def test_execute_downloads_all_images(self, monkeypatch, tmp_path): + """The bug: n=3 returned images_generated=3 but downloaded 1 file. + Mock the DashScope response with 3 URLs and verify all 3 are saved.""" + monkeypatch.setenv("DASHSCOPE_API_KEY", "fake-key") + + class FakeResp: + def __init__(self, payload, content=b""): + self._payload = payload + self.content = content + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + api_response = { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": [{"image": f"https://x/{i}.png"}]}} + for i in range(1, 4) + ] + }, + "usage": {"image_count": 3}, + } + + import requests + + monkeypatch.setattr( + requests, "post", lambda *a, **kw: FakeResp(api_response) + ) + monkeypatch.setattr( + requests, + "get", + lambda url, **kw: FakeResp({}, content=f"img-{url}".encode()), + ) + + out = tmp_path / "shot.png" + result = DashscopeImage().execute({ + "prompt": "test", "n": 3, "output_path": str(out), + }) + + assert result.success is True + assert result.data["images_generated"] == 3 + assert len(result.artifacts) == 3 + assert (tmp_path / "shot_1.png").exists() + assert (tmp_path / "shot_2.png").exists() + assert (tmp_path / "shot_3.png").exists() + + def test_execute_single_image_uses_base_path(self, monkeypatch, tmp_path): + """n=1 must keep the legacy single-path behavior (no _1 suffix).""" + monkeypatch.setenv("DASHSCOPE_API_KEY", "fake-key") + + class FakeResp: + def __init__(self, payload, content=b""): + self._payload = payload + self.content = content + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + api_response = { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": [{"image": "https://x/1.png"}]}} + ] + }, + "usage": {"image_count": 1}, + } + + import requests + + monkeypatch.setattr( + requests, "post", lambda *a, **kw: FakeResp(api_response) + ) + monkeypatch.setattr( + requests, + "get", + lambda url, **kw: FakeResp({}, content=b"img-bytes"), + ) + + out = tmp_path / "shot.png" + result = DashscopeImage().execute({ + "prompt": "test", "n": 1, "output_path": str(out), + }) + + assert result.success is True + assert result.data["images_generated"] == 1 + assert result.artifacts == [str(out)] + assert out.exists() + assert not (tmp_path / "shot_1.png").exists() + + +class TestDashscopeIdempotencyKeys: + """Regression tests for PR #240 review: idempotency keys must include + all output-affecting fields so different requests don't collide and + reuse stale artifacts.""" + + def test_image_idempotency_includes_all_output_fields(self): + fields = DashscopeImage().idempotency_key_fields + for field in ( + "prompt", "model", "size", "n", + "negative_prompt", "seed", "prompt_extend", "watermark", + ): + assert field in fields, f"image idempotency missing {field}" + + def test_image_idempotency_differs_on_negative_prompt(self): + tool = DashscopeImage() + base = {"prompt": "x", "model": "m", "size": "1024*1024", "n": 1} + assert tool.idempotency_key(base) != tool.idempotency_key( + {**base, "negative_prompt": "blurry"} + ) + + def test_image_idempotency_differs_on_seed(self): + tool = DashscopeImage() + base = {"prompt": "x", "model": "m", "size": "1024*1024", "n": 1} + assert tool.idempotency_key(base) != tool.idempotency_key( + {**base, "seed": 42} + ) + + def test_image_idempotency_differs_on_prompt_extend(self): + tool = DashscopeImage() + base = {"prompt": "x", "model": "m", "size": "1024*1024", "n": 1} + assert tool.idempotency_key( + {**base, "prompt_extend": True} + ) != tool.idempotency_key({**base, "prompt_extend": False}) + + def test_image_idempotency_differs_on_watermark(self): + tool = DashscopeImage() + base = {"prompt": "x", "model": "m", "size": "1024*1024", "n": 1} + assert tool.idempotency_key( + {**base, "watermark": False} + ) != tool.idempotency_key({**base, "watermark": True}) + + def test_tts_idempotency_includes_instructions(self): + assert "instructions" in DashscopeTTS().idempotency_key_fields + + def test_tts_idempotency_differs_on_instructions(self): + tool = DashscopeTTS() + base = { + "text": "hi", "voice": "Cherry", + "model": "qwen3-tts-flash", "language_type": "Auto", + } + assert tool.idempotency_key(base) != tool.idempotency_key( + {**base, "instructions": "speak softly"} + ) + + def test_asr_idempotency_includes_enable_words_and_language_hints(self): + fields = DashscopeAsr().idempotency_key_fields + assert "enable_words" in fields + assert "language_hints" in fields + + def test_asr_idempotency_differs_on_enable_words(self): + tool = DashscopeAsr() + base = {"audio_url": "https://x/a.mp3", "model": "qwen3-asr-flash-filetrans"} + assert tool.idempotency_key( + {**base, "enable_words": True} + ) != tool.idempotency_key({**base, "enable_words": False}) + + def test_asr_idempotency_differs_on_language_hints(self): + tool = DashscopeAsr() + base = {"audio_url": "https://x/a.mp3", "model": "qwen3-asr-flash-filetrans"} + assert tool.idempotency_key( + {**base, "language_hints": ["zh"]} + ) != tool.idempotency_key( + {**base, "language_hints": ["zh", "en"]} + ) + + +# ------------------------------------------------------------------ +# TTS-specific tests +# ------------------------------------------------------------------ + +class TestDashscopeTtsSpecific: + + def test_default_model_is_qwen3_tts_flash(self): + tool = DashscopeTTS() + assert tool.input_schema["properties"]["model"]["default"] == "qwen3-tts-flash" + + def test_default_voice_is_cherry(self): + tool = DashscopeTTS() + assert tool.input_schema["properties"]["voice"]["default"] == "Cherry" + + def test_default_language_is_auto(self): + tool = DashscopeTTS() + assert tool.input_schema["properties"]["language_type"]["default"] == "Auto" + + def test_cost_scales_with_text_length(self): + tool = DashscopeTTS() + cost_short = tool.estimate_cost({"text": "hi"}) + cost_long = tool.estimate_cost({"text": "hi " * 100}) + assert cost_long > cost_short + + def test_build_payload_includes_input_text_voice(self): + tool = DashscopeTTS() + payload = tool._build_payload({"text": "hello", "voice": "Ethan"}) + assert payload["input"]["text"] == "hello" + assert payload["input"]["voice"] == "Ethan" + + def test_build_payload_adds_instructions_for_instruct_model(self): + tool = DashscopeTTS() + payload = tool._build_payload({ + "text": "hello", + "instructions": "speak softly", + }) + assert payload["input"]["instructions"] == "speak softly" + assert payload["input"]["optimize_instructions"] is True + + def test_fallback_includes_piper(self): + """Piper is the free offline fallback — must be in fallback list.""" + tool = DashscopeTTS() + assert "piper_tts" in tool.fallback_tools + + def test_safe_error_redacts_key(self, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "secret-key-12345") + redacted = DashscopeTTS._safe_error( + Exception("failed with key secret-key-12345") + ) + assert "secret-key-12345" not in redacted + assert "[redacted]" in redacted + + +# ------------------------------------------------------------------ +# ASR-specific tests +# ------------------------------------------------------------------ + +class TestDashscopeAsrSpecific: + + def test_default_model_is_filetrans(self): + """CRITICAL: must use qwen3-asr-flash-filetrans, NOT qwen3-asr-flash. + The sync version does not support word-level timestamps.""" + tool = DashscopeAsr() + assert tool.input_schema["properties"]["model"]["default"] == "qwen3-asr-flash-filetrans" + + def test_execution_mode_is_async(self): + tool = DashscopeAsr() + assert tool.execution_mode == ExecutionMode.ASYNC + + def test_default_enable_words_is_true(self): + """Word-level timestamps must be enabled by default.""" + tool = DashscopeAsr() + assert tool.input_schema["properties"]["enable_words"]["default"] is True + + def test_default_language_hints_includes_zh_en(self): + tool = DashscopeAsr() + hints = tool.input_schema["properties"]["language_hints"]["default"] + assert "zh" in hints + assert "en" in hints + + def test_rejects_local_file_path(self, monkeypatch): + """audio_url must be a public URL — local paths are rejected.""" + monkeypatch.setenv("DASHSCOPE_API_KEY", "fake-key-for-testing") + tool = DashscopeAsr() + result = tool.execute({"audio_url": "/local/path/audio.mp3"}) + assert result.success is False + assert "publicly accessible URL" in result.error + + def test_rejects_relative_path(self, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "fake-key-for-testing") + tool = DashscopeAsr() + result = tool.execute({"audio_url": "audio.mp3"}) + assert result.success is False + assert "publicly accessible URL" in result.error + + def test_rejects_empty_url(self, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "fake-key-for-testing") + tool = DashscopeAsr() + result = tool.execute({"audio_url": ""}) + assert result.success is False + assert "required" in result.error.lower() + + def test_rejects_no_key(self, monkeypatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + tool = DashscopeAsr() + result = tool.execute({"audio_url": "https://example.com/audio.mp3"}) + assert result.success is False + assert "DASHSCOPE_API_KEY" in result.error + + def test_build_payload_enables_words(self): + tool = DashscopeAsr() + payload = tool._build_payload({"audio_url": "https://x.com/a.mp3"}) + assert payload["parameters"]["enable_words"] is True + + def test_build_payload_includes_file_url(self): + """qwen3-asr-flash-filetrans uses file_url (singular string), + NOT file_urls (plural array) like paraformer-v2.""" + tool = DashscopeAsr() + payload = tool._build_payload({"audio_url": "https://x.com/a.mp3"}) + assert payload["input"]["file_url"] == "https://x.com/a.mp3" + + def test_extract_words_normalizes_ms_to_seconds(self): + """Word timestamps from DashScope are in milliseconds; the tool + must normalize to seconds for downstream subtitle building.""" + fake_transcription = { + "transcripts": [ + { + "sentences": [ + { + "words": [ + {"text": "hello", "begin_time": 1000, "end_time": 1500}, + {"text": "world", "begin_time": 1500, "end_time": 2000}, + ] + } + ] + } + ] + } + words = DashscopeAsr._extract_words(fake_transcription) + assert len(words) == 2 + assert words[0]["text"] == "hello" + assert words[0]["begin_time_seconds"] == 1.0 + assert words[0]["end_time_seconds"] == 1.5 + assert words[1]["begin_time_seconds"] == 1.5 + assert words[1]["end_time_seconds"] == 2.0 + + def test_extract_words_handles_empty_transcription(self): + words = DashscopeAsr._extract_words({}) + assert words == [] + + def test_is_public_url_accepts_https(self): + assert DashscopeAsr._is_public_url("https://example.com/audio.mp3") is True + + def test_is_public_url_rejects_local(self): + assert DashscopeAsr._is_public_url("/local/path/audio.mp3") is False + assert DashscopeAsr._is_public_url("audio.mp3") is False + assert DashscopeAsr._is_public_url("ftp://example.com/audio.mp3") is False + + def test_safe_error_redacts_key(self, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "secret-key-12345") + redacted = DashscopeAsr._safe_error( + Exception("failed with key secret-key-12345") + ) + assert "secret-key-12345" not in redacted + assert "[redacted]" in redacted + + +# ------------------------------------------------------------------ +# Registry discovery +# ------------------------------------------------------------------ + +class TestDashscopeRegistryDiscovery: + + def test_all_three_tools_discoverable(self): + from tools.tool_registry import ToolRegistry + registry = ToolRegistry() + registry.discover() + dashscope_tools = [ + t for t in registry._tools.values() + if t.provider == "dashscope" + ] + names = {t.name for t in dashscope_tools} + assert names == {"dashscope_image", "dashscope_tts", "dashscope_asr"} + + def test_image_selector_finds_dashscope(self): + """image_selector should auto-discover dashscope_image by capability.""" + from tools.graphics.image_selector import ImageSelector + selector = ImageSelector() + # Selector discovers providers by capability="image_generation" + # dashscope_image has that capability, so it should be routable + assert DashscopeImage().capability == "image_generation" + + def test_tts_selector_finds_dashscope(self): + """tts_selector should auto-discover dashscope_tts by capability.""" + from tools.audio.tts_selector import TTSSelector + selector = TTSSelector() + assert DashscopeTTS().capability == "tts" diff --git a/tests/contracts/test_phase3_contracts.py b/tests/contracts/test_phase3_contracts.py index 17b93adb..6f225f14 100644 --- a/tests/contracts/test_phase3_contracts.py +++ b/tests/contracts/test_phase3_contracts.py @@ -5,6 +5,8 @@ stage director skills, meta skills, and the animated-explainer pipeline. """ import sys +import builtins +import shutil from pathlib import Path import pytest @@ -23,7 +25,7 @@ from lib.pipeline_loader import ( from lib.checkpoint import STAGES from schemas.artifacts import list_schemas from styles.playbook_loader import load_playbook, list_playbooks, validate_playbook -from tools.base_tool import ToolTier +from tools.base_tool import ToolTier, ToolStatus from tools.audio.music_gen import MusicGen from tools.tool_registry import ToolRegistry from tools.audio.elevenlabs_tts import ElevenLabsTTS @@ -73,6 +75,22 @@ class TestPiperTTS: assert "text_to_speech" in tool.capabilities assert "offline_generation" in tool.capabilities + def test_status_requires_piper_executable_even_if_python_package_imports(self, monkeypatch): + """F-12 regression: Piper generation shells out to `piper`, so importing + the Python package is not enough to mark the provider available.""" + original_import = builtins.__import__ + original_which = shutil.which + + def fake_import(name, *args, **kwargs): + if name == "piper": + return object() + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(shutil, "which", lambda cmd: None if cmd == "piper" else original_which(cmd)) + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert PiperTTS().get_status() == ToolStatus.UNAVAILABLE + class TestMusicGen: def test_identity(self): @@ -143,7 +161,14 @@ class TestCapabilityMetadata: catalog = reg.capability_catalog() assert "tts" in catalog providers = {item["provider"] for item in catalog["tts"] if item["provider"] != "selector"} - assert providers == {"doubao", "elevenlabs", "google_tts", "openai", "piper"} + assert providers == { + "dashscope", + "doubao", + "elevenlabs", + "google_tts", + "openai", + "piper", + } # ---- Animated Explainer Pipeline ---- diff --git a/tests/contracts/test_taste_governance_contracts.py b/tests/contracts/test_taste_governance_contracts.py new file mode 100644 index 00000000..0ed28532 --- /dev/null +++ b/tests/contracts/test_taste_governance_contracts.py @@ -0,0 +1,104 @@ +"""Contract tests for taste-direction governance. + +The taste-direction meta skill is an agent-facing contract: it must be easy to +discover, and its output must fit the canonical proposal/style artifacts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import yaml + + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _taste_profile() -> dict: + return { + "design_read": "Premium expert explainer: calm authority, high trust, low ornament.", + "visual_variance": 4, + "motion_intensity": 3, + "information_density": 5, + "palette_discipline": "Neutral base, one blue accent, no decorative gradients.", + "layout_variation": "Alternate editorial split frames with data-forward full-frame scenes.", + "reference_strategy": "One reference still per scene family before asset generation.", + "anti_patterns": [ + "generic AI-purple gradient backgrounds", + "reusing the same transition on every cut", + ], + "quality_gates": [ + "Each scene carries the design read without needing explanatory labels.", + "Motion stays purposeful and never outruns narration comprehension.", + ], + } + + +def test_style_playbook_schema_accepts_taste_profile(): + schema = _load_json(ROOT / "schemas" / "styles" / "playbook.schema.json") + assert "taste_profile" in schema["properties"] + playbook = yaml.safe_load((ROOT / "styles" / "clean-professional.yaml").read_text(encoding="utf-8")) + playbook["taste_profile"] = _taste_profile() + + jsonschema.validate(instance=playbook, schema=schema) + + +def test_proposal_packet_schema_accepts_taste_profile(): + schema = _load_json(ROOT / "schemas" / "artifacts" / "proposal_packet.schema.json") + proposal = { + "version": "1.0", + "concept_options": [ + { + "id": f"c{i}", + "title": f"Concept {i}", + "hook": "A precise hook under twenty words.", + "narrative_structure": "problem_solution", + "visual_approach": "Premium minimalist scenes with data-led visual proof.", + "target_duration_seconds": 60, + "why_this_works": "It ties the audience problem to a visible payoff.", + } + for i in range(1, 4) + ], + "selected_concept": {"concept_id": "c1", "rationale": "Best fit for the brief."}, + "production_plan": { + "pipeline": "animated-explainer", + "stages": [{"stage": "proposal", "tools": [], "approach": "Plan the production."}], + "render_runtime": "remotion", + "taste_profile": _taste_profile(), + }, + "cost_estimate": { + "total_estimated_usd": 0, + "line_items": [], + "budget_verdict": "no_budget_set", + }, + "approval": {"status": "pending"}, + } + + jsonschema.validate(instance=proposal, schema=schema) + + +def test_taste_direction_is_discoverable_to_new_agents(): + skill_path = ROOT / "skills" / "meta" / "taste-direction.md" + assert skill_path.is_file(), "Missing Layer 2 taste-direction meta skill" + + index = (ROOT / "skills" / "INDEX.md").read_text(encoding="utf-8") + assert "Taste Direction" in index + assert "meta/taste-direction.md" in index + + guide = (ROOT / "AGENT_GUIDE.md").read_text(encoding="utf-8") + assert "taste-direction.md" in guide + + +def test_premium_minimalist_playbook_exists_and_validates(): + from styles.playbook_loader import load_playbook, list_playbooks + + assert "premium-minimalist" in list_playbooks() + playbook = load_playbook("premium-minimalist") + assert playbook["taste_profile"]["motion_intensity"] <= 4 + assert playbook["taste_profile"]["information_density"] >= 4 diff --git a/tests/lib/test_source_media_review_empty.py b/tests/lib/test_source_media_review_empty.py new file mode 100644 index 00000000..54ed39fa --- /dev/null +++ b/tests/lib/test_source_media_review_empty.py @@ -0,0 +1,29 @@ +"""Regression test for source_media_review empty-files artifact validity. + +review_source_media deliberately returns an artifact with files:[] when no user +media was supplied (or none could be reviewed) — a valid "fully generated +production" state. The schema declared files.minItems: 1, so that intended +artifact failed its own schema validation. +""" + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from lib.source_media_review import review_source_media # noqa: E402 +from schemas.artifacts import validate_artifact # noqa: E402 + + +def test_no_source_media_produces_schema_valid_artifact(tmp_path): + art = review_source_media([tmp_path / "does-not-exist.mp4"], {}) + assert art["files"] == [] + # Must not raise — this is a legitimate no-source-media artifact. + validate_artifact("source_media_review", art) + + +def test_no_files_at_all_is_schema_valid(): + art = review_source_media([], {}) + assert art["files"] == [] + validate_artifact("source_media_review", art) diff --git a/tests/lib/test_variation_checker_runs.py b/tests/lib/test_variation_checker_runs.py new file mode 100644 index 00000000..fcec201f --- /dev/null +++ b/tests/lib/test_variation_checker_runs.py @@ -0,0 +1,37 @@ +"""Regression test for check_scene_variation consecutive-run counting. + +The "consecutive same-size shots" check summed every equal adjacent pair across +the whole plan instead of measuring the longest actual run, so an editorially +varied plan of separate 2-shot groups (wide,wide,cu,cu,med,med) falsely tripped +a "3 consecutive same-size shots" violation. +""" + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from lib.variation_checker import check_scene_variation # noqa: E402 + + +def _scenes(sizes): + return [{"shot_language": {"shot_size": s}} for s in sizes] + + +def test_non_consecutive_same_size_pairs_do_not_trip_run_check(): + # Three separate 2-shot groups — longest run is 2, not 3. + res = check_scene_variation(_scenes(["wide", "wide", "cu", "cu", "medium", "medium"])) + assert not any("consecutive same-size" in v for v in res["violations"]) + + +def test_true_run_of_three_is_flagged(): + res = check_scene_variation(_scenes(["wide", "wide", "wide", "cu", "medium"])) + assert any("3 consecutive same-size" in v for v in res["violations"]) + + +def test_unspecified_shots_do_not_form_a_run(): + res = check_scene_variation( + _scenes(["unspecified", "unspecified", "unspecified", "unspecified"]) + ) + assert not any("consecutive same-size" in v for v in res["violations"]) diff --git a/tests/qa/QA_PLAN.md b/tests/qa/QA_PLAN.md index d2656307..961fef86 100644 --- a/tests/qa/QA_PLAN.md +++ b/tests/qa/QA_PLAN.md @@ -9,7 +9,7 @@ Run every tool with real API keys, inspect outputs (see images, listen to audio, | Script | Tools Tested | API Keys Used | Est. Cost | |--------|-------------|---------------|-----------| | `test_01_tts.py` | `elevenlabs_tts` (ElevenLabs) | ELEVENLABS_API_KEY | ~$0.02 | -| `test_02_image_gen.py` | `image_gen` (DALL-E 3 + FLUX) | OPENAI_API_KEY, FAL_AI_API_KEY | ~$0.15 | +| `test_02_image_gen.py` | `image_gen` (GPT Image 2 + FLUX) | OPENAI_API_KEY, FAL_AI_API_KEY | ~$0.15 | | `test_03_music.py` | `music_gen` (ElevenLabs) | ELEVENLABS_API_KEY | ~$0.10 | | `test_04_audio_mix.py` | `audio_mixer` | None (ffmpeg only) | $0 | | `test_05_video_compose.py` | `video_compose` | None (ffmpeg only) | $0 | @@ -30,7 +30,7 @@ For each output: | Area | Risk | How to Validate | |------|------|-----------------| | TTS voice selection | Default voice may not match playbook mood | Test with multiple voice IDs, compare against playbook `voice_style` | -| Image gen consistency | DALL-E/FLUX outputs vary wildly per prompt | Test with playbook `image_prompt_prefix` prepended | +| Image gen consistency | GPT Image/FLUX outputs vary wildly per prompt | Test with playbook `image_prompt_prefix` prepended | | Music duration alignment | Music may not match narration duration | Compare `music.duration` vs `tts.duration`, check padding/looping | | Audio ducking timing | Ducking may cut music too aggressively | Inspect waveform: music should duck ~6dB under speech, recover smoothly | | Video stitch transitions | Crossfade may flicker with mismatched codecs | Test with both matching and mismatched clips, check `auto_normalize` | diff --git a/tests/qa/test_08_end_to_end.py b/tests/qa/test_08_end_to_end.py index cbbee2da..bef19072 100644 --- a/tests/qa/test_08_end_to_end.py +++ b/tests/qa/test_08_end_to_end.py @@ -234,7 +234,7 @@ except Exception as e: check("Proposal packet validates against schema", False, str(e)) cp_path = write_checkpoint( - PIPELINE_DIR, PROJECT_ID, "proposal", "completed", + PIPELINE_DIR, PROJECT_ID, "proposal", "completed", human_approved=True, artifacts={"proposal_packet": proposal_packet}, pipeline_type="animated-explainer", style_playbook="clean-professional", @@ -283,7 +283,7 @@ except Exception as e: check("Script validates against schema", False, str(e)) write_checkpoint( - PIPELINE_DIR, PROJECT_ID, "script", "completed", + PIPELINE_DIR, PROJECT_ID, "script", "completed", human_approved=True, artifacts={"script": script}, pipeline_type="animated-explainer", ) @@ -322,7 +322,7 @@ except Exception as e: check("Scene plan validates against schema", False, str(e)) write_checkpoint( - PIPELINE_DIR, PROJECT_ID, "scene_plan", "completed", + PIPELINE_DIR, PROJECT_ID, "scene_plan", "completed", human_approved=True, artifacts={"scene_plan": scene_plan}, pipeline_type="animated-explainer", ) @@ -401,7 +401,7 @@ tracker.reconcile(eid, 0.0, success=True) print(f" Cost snapshot: {tracker.cost_snapshot()}") write_checkpoint( - PIPELINE_DIR, PROJECT_ID, "assets", "completed", + PIPELINE_DIR, PROJECT_ID, "assets", "completed", human_approved=True, artifacts={"asset_manifest": asset_manifest}, pipeline_type="animated-explainer", cost_snapshot=tracker.cost_snapshot(), @@ -615,7 +615,7 @@ except Exception as e: check("Publish log validates against schema", False, str(e)) write_checkpoint( - PIPELINE_DIR, PROJECT_ID, "publish", "completed", + PIPELINE_DIR, PROJECT_ID, "publish", "completed", human_approved=True, artifacts={"publish_log": publish_log}, pipeline_type="animated-explainer", ) diff --git a/tests/tools/test_audio_mixer_ducking.py b/tests/tools/test_audio_mixer_ducking.py new file mode 100644 index 00000000..ec060e81 --- /dev/null +++ b/tests/tools/test_audio_mixer_ducking.py @@ -0,0 +1,89 @@ +"""Regression tests for audio_mixer full_mix ducking filtergraph. + +The ducking branch built an `acopy[speech_dup]` filter whose output pad was +never consumed, leaving the FFmpeg filtergraph with a dangling output. FFmpeg +rejects that, so `full_mix` with the most common shape — a single narration +track plus one music bed, with ducking enabled (the default) — always failed. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.audio.audio_mixer import AudioMixer # noqa: E402 + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None, reason="ffmpeg required for full_mix" +) + + +def _sine(path: Path, freq: int, dur: int) -> None: + subprocess.run( + ["ffmpeg", "-y", "-f", "lavfi", "-i", f"sine=frequency={freq}:duration={dur}", str(path)], + capture_output=True, + check=True, + timeout=30, + ) + + +def _has_audio(path: Path) -> bool: + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a", + "-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)], + capture_output=True, text=True, timeout=30, + ) + return "audio" in out.stdout + + +def test_full_mix_single_narration_plus_music_with_ducking(tmp_path): + speech = tmp_path / "speech.wav" + music = tmp_path / "music.wav" + _sine(speech, 440, 2) + _sine(music, 220, 3) + out = tmp_path / "mixed.wav" + + result = AudioMixer().execute( + { + "operation": "full_mix", + "tracks": [ + {"path": str(speech), "role": "speech"}, + {"path": str(music), "role": "music"}, + ], + "ducking": {"enabled": True}, + "output_path": str(out), + } + ) + + assert result.success is True, result.error + assert out.exists() and _has_audio(out) + + +def test_full_mix_multi_narration_plus_music_with_ducking(tmp_path): + s1, s2 = tmp_path / "s1.wav", tmp_path / "s2.wav" + music = tmp_path / "music.wav" + _sine(s1, 440, 2) + _sine(s2, 330, 2) + _sine(music, 220, 3) + out = tmp_path / "mixed_multi.wav" + + result = AudioMixer().execute( + { + "operation": "full_mix", + "tracks": [ + {"path": str(s1), "role": "speech"}, + {"path": str(s2), "role": "speech"}, + {"path": str(music), "role": "music"}, + ], + "ducking": {"enabled": True}, + "output_path": str(out), + } + ) + + assert result.success is True, result.error + assert out.exists() and _has_audio(out) diff --git a/tests/tools/test_delivery_promise.py b/tests/tools/test_delivery_promise.py new file mode 100644 index 00000000..9a6fe1c6 --- /dev/null +++ b/tests/tools/test_delivery_promise.py @@ -0,0 +1,26 @@ +from lib.delivery_promise import PromiseType, classify_from_brief + + +def test_classify_from_brief_source_led_reclassification_clears_motion_requirement() -> None: + promise = classify_from_brief("talking-head", {"has_footage": True}) + + assert promise.promise_type == PromiseType.SOURCE_LED + assert promise.source_required is True + assert promise.motion_required is False + + +def test_classify_from_brief_explicit_motion_override_survives_reclassification() -> None: + promise = classify_from_brief( + "talking-head", + {"has_footage": True, "motion_required": True}, + ) + + assert promise.promise_type == PromiseType.SOURCE_LED + assert promise.motion_required is True + + +def test_classify_from_brief_avatar_defaults_stay_motion_required_without_footage() -> None: + promise = classify_from_brief("talking-head", {}) + + assert promise.promise_type == PromiseType.AVATAR_PRESENTER + assert promise.motion_required is True diff --git a/tests/tools/test_documentary_governance.py b/tests/tools/test_documentary_governance.py index bb96b73c..8f498c95 100644 --- a/tests/tools/test_documentary_governance.py +++ b/tests/tools/test_documentary_governance.py @@ -4,7 +4,9 @@ from pathlib import Path from tools.base_tool import ToolStatus from tools.tool_registry import ToolRegistry +from tools.video.stock_sources import Candidate from tools.video.corpus_builder import CorpusBuilder +from tools.video.direct_clip_search import DirectClipSearch from tools.video.video_compose import VideoCompose @@ -196,3 +198,196 @@ def test_provider_menu_preserves_tool_discovery_metadata(monkeypatch): assert entry["name"] == "corpus_builder" assert entry["source_provider_summary"]["configured"] == 1 assert entry["source_provider_menu"][0]["name"] == "archive_org" + + +def test_direct_clip_search_honors_overall_timeout(monkeypatch, tmp_path): + """F-13 regression: direct clip search must stop on its own deadline and + return partial progress instead of relying on an external PTY interrupt.""" + import tools.video.direct_clip_search as direct_clip_search + import tools.video.stock_sources as stock_sources + + class SlowSource(_DummySource): + def search(self, query: str, filters): + return [ + Candidate( + source=self.name, + source_id="slow-1", + source_url="https://example.test/slow-1", + download_url="https://example.test/slow-1.mp4", + kind="video", + ) + ] + + def download(self, candidate, out_path: Path): + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(b"0" * 2048) + return out_path + + source = SlowSource("slow_source", True) + monkeypatch.setattr(stock_sources, "all_sources", lambda: [source]) + monkeypatch.setattr(stock_sources, "available_sources", lambda: [source]) + monkeypatch.setattr( + stock_sources, + "source_summary", + lambda: { + "configured": 1, + "total": 1, + "available_source_names": ["slow_source"], + "unavailable_source_names": [], + }, + ) + + ticks = iter([0.0, 2.0, 2.0, 2.0]) + monkeypatch.setattr(direct_clip_search.time, "time", lambda: next(ticks, 2.0)) + + result = DirectClipSearch().execute( + { + "output_dir": str(tmp_path / "clips"), + "queries": [{"query": "foggy harbor", "slot_id": "sc5"}], + "timeout_seconds": 1, + "extract_thumbnails": False, + } + ) + + assert not result.success + assert "timed out" in (result.error or "").lower() + assert result.data["timed_out"] is True + assert result.data["phase"] in {"query", "search", "download"} + assert result.data["clips"] == [] + + +def test_direct_clip_search_times_out_streaming_download(monkeypatch, tmp_path): + """F-13 regression: a streaming adapter download must not run past the + tool-level deadline just because bytes keep arriving.""" + import tools.video.direct_clip_search as direct_clip_search + import tools.video.stock_sources as stock_sources + import requests + + clock = {"now": 0.0} + + class StreamingResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size=1024): + clock["now"] = 2.0 + yield b"0" * 2048 + + class StreamingSource(_DummySource): + def search(self, query: str, filters): + return [ + Candidate( + source=self.name, + source_id="stream-1", + source_url="https://example.test/stream-1", + download_url="https://example.test/stream-1.mp4", + kind="video", + ) + ] + + def download(self, candidate, out_path: Path): + out_path.parent.mkdir(parents=True, exist_ok=True) + with requests.get(candidate.download_url, stream=True, timeout=300) as response: + response.raise_for_status() + with out_path.open("wb") as f: + for chunk in response.iter_content(chunk_size=1024): + if chunk: + f.write(chunk) + return out_path + + source = StreamingSource("streaming_source", True) + monkeypatch.setattr(stock_sources, "all_sources", lambda: [source]) + monkeypatch.setattr(stock_sources, "available_sources", lambda: [source]) + monkeypatch.setattr( + stock_sources, + "source_summary", + lambda: { + "configured": 1, + "total": 1, + "available_source_names": ["streaming_source"], + "unavailable_source_names": [], + }, + ) + monkeypatch.setattr(direct_clip_search.time, "time", lambda: clock["now"]) + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: StreamingResponse()) + + result = DirectClipSearch().execute( + { + "output_dir": str(tmp_path / "clips"), + "queries": [{"query": "foggy harbor", "slot_id": "sc5"}], + "timeout_seconds": 1, + "extract_thumbnails": False, + } + ) + + assert not result.success + assert result.data["timed_out"] is True + assert result.data["phase"] == "download" + assert result.data["clips"] == [] + + +def test_direct_clip_search_reports_downloaded_clip_when_thumbnail_times_out( + monkeypatch, tmp_path +): + """F-13 regression: timeout data should include a clip that was already + downloaded and validated before thumbnail extraction hit the deadline.""" + import tools.video.direct_clip_search as direct_clip_search + import tools.video.stock_sources as stock_sources + + clock = {"now": 0.0} + + class SlowThumbnailSource(_DummySource): + def search(self, query: str, filters): + return [ + Candidate( + source=self.name, + source_id="thumb-1", + source_url="https://example.test/thumb-1", + download_url="https://example.test/thumb-1.mp4", + kind="video", + ) + ] + + def download(self, candidate, out_path: Path): + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(b"0" * 2048) + clock["now"] = 2.0 + return out_path + + source = SlowThumbnailSource("thumb_source", True) + monkeypatch.setattr(stock_sources, "all_sources", lambda: [source]) + monkeypatch.setattr(stock_sources, "available_sources", lambda: [source]) + monkeypatch.setattr( + stock_sources, + "source_summary", + lambda: { + "configured": 1, + "total": 1, + "available_source_names": ["thumb_source"], + "unavailable_source_names": [], + }, + ) + monkeypatch.setattr(direct_clip_search.time, "time", lambda: clock["now"]) + + result = DirectClipSearch().execute( + { + "output_dir": str(tmp_path / "clips"), + "queries": [{"query": "foggy harbor", "slot_id": "sc5"}], + "timeout_seconds": 1, + "extract_thumbnails": True, + } + ) + + assert not result.success + assert result.data["timed_out"] is True + assert result.data["phase"] == "thumbnail" + assert result.data["clips_downloaded"] == 1 + assert result.data["total_clips"] == 1 + assert result.data["clips"][0]["clip_id"] == "thumb_source_thumb-1" + assert result.data["clips"][0]["thumbnail"] == "" diff --git a/tests/tools/test_export_bundle.py b/tests/tools/test_export_bundle.py new file mode 100644 index 00000000..74e30c49 --- /dev/null +++ b/tests/tools/test_export_bundle.py @@ -0,0 +1,155 @@ +"""Tests for the export_bundle publisher tool. + +Covers the tool contract, registry discovery, the export bundle layout, a +schema-valid publish_log, chapter formatting, and the missing-video error path. +""" + +import json +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.publishers.export_bundle import ExportBundle +from tools.base_tool import ToolStatus, ToolTier +from tools.tool_registry import ToolRegistry +from schemas.artifacts import validate_artifact + + +def _make_video(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\x00\x00\x00\x18ftypmp42fakevideo") + + +def test_contract_metadata(): + tool = ExportBundle() + info = tool.get_info() + assert info["name"] == "export_bundle" + assert info["capability"] == "publish" + assert info["tier"] == ToolTier.PUBLISH.value + assert info["provider"] == "local" + assert info["resource_profile"]["network_required"] is False + assert tool.get_status() == ToolStatus.AVAILABLE + assert tool.estimate_cost({}) == 0.0 + + +def test_missing_video_errors(tmp_path): + result = ExportBundle().execute( + {"video_path": str(tmp_path / "nope.mp4"), "title": "X"} + ) + assert result.success is False + assert "not found" in (result.error or "") + + +def test_export_bundle_layout_and_publish_log(tmp_path): + video = tmp_path / "projects" / "demo" / "renders" / "final.mp4" + _make_video(video) + subs = tmp_path / "subs.srt" + subs.write_text("1\n00:00:00,000 --> 00:00:01,000\nhi\n", encoding="utf-8") + + result = ExportBundle().execute( + { + "video_path": str(video), + "title": "Vector Databases Explained in 60 Seconds", + "export_dir": str(tmp_path / "out"), + "description": "A quick explainer.", + "tags": ["vector db", "explainer"], + "hashtags": ["#ai", "#database"], + "chapters": [ + {"start_seconds": 0, "title": "Intro"}, + {"start_seconds": 75, "title": "How it works"}, + ], + "subtitles_path": str(subs), + "thumbnail_concept": {"text_overlay": "100x FASTER"}, + "platform": "youtube", + "visibility": "unlisted", + "timestamp": "2026-06-29T10:30:00+00:00", + } + ) + assert result.success is True + root = Path(result.data["export_path"]) + + # Layout + assert (root / "video" / "output.mp4").is_file() + assert (root / "video" / "subtitles.srt").is_file() + assert (root / "metadata" / "metadata.json").is_file() + assert (root / "metadata" / "description.txt").is_file() + assert (root / "metadata" / "tags.txt").is_file() + assert (root / "metadata" / "chapters.txt").is_file() + assert (root / "thumbnails" / "concept.json").is_file() + + # tags one-per-line + assert (root / "metadata" / "tags.txt").read_text().splitlines() == ["vector db", "explainer"] + # chapter formatting (75s -> 1:15) + assert "1:15 - How it works" in (root / "metadata" / "chapters.txt").read_text() + + # publish_log is schema-valid and shaped right + plog = result.data["publish_log"] + validate_artifact("publish_log", plog) + entry = plog["entries"][0] + assert entry["status"] == "exported" + assert entry["platform"] == "youtube" + assert entry["visibility"] == "unlisted" + assert entry["export_path"] == str(root) + assert entry["metadata_used"]["title"].startswith("Vector Databases") + + +def test_chapter_time_formatting_hours(tmp_path): + video = tmp_path / "p" / "renders" / "final.mp4" + _make_video(video) + result = ExportBundle().execute( + { + "video_path": str(video), + "title": "Long", + "export_dir": str(tmp_path / "out"), + "chapters": [{"time_seconds": 3725, "label": "Deep dive"}], # 1:02:05 + } + ) + assert result.success is True + txt = (Path(result.data["export_path"]) / "metadata" / "chapters.txt").read_text() + assert "1:02:05 - Deep dive" in txt + + +def test_infer_project_name(tmp_path): + video = tmp_path / "projects" / "my-cool-video" / "renders" / "final.mp4" + _make_video(video) + result = ExportBundle().execute( + {"video_path": str(video), "title": "T", "export_dir": str(tmp_path / "out")} + ) + # export still works; project name inference exercised via no-export_dir path below + assert result.success is True + + +def test_missing_optional_asset_errors(tmp_path): + video = tmp_path / "p" / "renders" / "final.mp4" + _make_video(video) + for key in ("subtitles_path", "thumbnail_path"): + result = ExportBundle().execute( + { + "video_path": str(video), + "title": "T", + "export_dir": str(tmp_path / "out"), + key: str(tmp_path / "does_not_exist.x"), + } + ) + assert result.success is False, key + assert key in (result.error or "") + + +def test_default_export_dir_inside_project_workspace(tmp_path): + # projects//renders/final.mp4 -> projects//exports (no export_dir given) + video = tmp_path / "projects" / "demo" / "renders" / "final.mp4" + _make_video(video) + result = ExportBundle().execute({"video_path": str(video), "title": "T"}) + assert result.success is True + assert Path(result.data["export_path"]) == (tmp_path / "projects" / "demo" / "exports").resolve() + + +def test_registry_discovers_export_bundle(): + reg = ToolRegistry() + reg.discover() + assert reg.get("export_bundle") is not None + assert reg.get_by_capability("publish")[0].name == "export_bundle" diff --git a/tests/tools/test_hyperframes_compose.py b/tests/tools/test_hyperframes_compose.py index dc465c3c..41997990 100644 --- a/tests/tools/test_hyperframes_compose.py +++ b/tests/tools/test_hyperframes_compose.py @@ -870,6 +870,45 @@ def test_video_compose_blocks_hyperframes_when_runtime_unavailable( assert "blocker" in err or "not available" in err +def test_video_compose_honors_hyperframes_runtime_before_atelier_mode( + tmp_path, monkeypatch +): + """Regression for F-14: composition_mode='atelier' must not force the + Remotion atelier branch when render_runtime='hyperframes' is locked.""" + + monkeypatch.setattr( + VideoCompose, "_hyperframes_available", lambda self: False, raising=True + ) + + result = VideoCompose().execute( + { + "operation": "render", + "edit_decisions": { + "version": "1.0", + "cuts": [ + { + "id": "c1", + "source": "a1", + "in_seconds": 0, + "out_seconds": 3, + } + ], + "render_runtime": "hyperframes", + "composition_mode": "atelier", + "renderer_family": "animation-first", + }, + "asset_manifest": {"assets": [{"id": "a1", "path": "does-not-matter.png"}]}, + "output_path": str(tmp_path / "out.mp4"), + } + ) + + assert not result.success + err = (result.error or "").lower() + assert "hyperframes" in err + assert "not available" in err or "blocker" in err + assert "remotion entry" not in err + + # ------------------------------------------------------------------ # Scaffold / workspace generation (no CLI invocation) # ------------------------------------------------------------------ diff --git a/tests/tools/test_math_animate_safety.py b/tests/tools/test_math_animate_safety.py new file mode 100644 index 00000000..561bbe06 --- /dev/null +++ b/tests/tools/test_math_animate_safety.py @@ -0,0 +1,190 @@ +"""Tests for math_animate scene_code safety scan (issue #219). + +math_animate executes caller-supplied Python via Manim. The static scan blocks +the constructs an attack needs (system/network/subprocess/secret access) while +leaving genuine math-animation scenes untouched, and can be bypassed only with +an explicit allow_unsafe_code opt-out. +""" + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.graphics.math_animate import MathAnimate # noqa: E402 + +SAFE_SCENE = ( + "from manim import *\n" + "import numpy as np\n" + "import math\n" + "class Demo(Scene):\n" + " def construct(self):\n" + " self.play(Create(Circle(radius=np.pi / math.tau)))\n" +) + + +def test_safe_scene_passes_scan(): + assert MathAnimate._scan_scene_code(SAFE_SCENE) == [] + + +@pytest.mark.parametrize( + "snippet, needle", + [ + ("import os\nos.environ", "import 'os'"), + ("import subprocess", "import 'subprocess'"), + ("import socket", "import 'socket'"), + ("from urllib.request import urlopen", "from 'urllib.request' import ..."), + ("import requests", "import 'requests'"), + ], +) +def test_blocks_dangerous_imports(snippet, needle): + code = f"from manim import *\n{snippet}\nclass S(Scene):\n def construct(self):\n pass\n" + violations = MathAnimate._scan_scene_code(code) + assert needle in violations + + +@pytest.mark.parametrize("call", ["eval", "exec", "compile", "open", "__import__"]) +def test_blocks_dangerous_calls(call): + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + f" {call}('x')\n" + ) + assert f"use of '{call}'" in MathAnimate._scan_scene_code(code) + + +def test_blocks_no_import_builtins_secret_read(): + # Regression for the reported bypass: no dangerous import, secret read via + # __builtins__ indexing. The whole expression roots on the bare __builtins__ + # name (the 'open' inside [] is a string literal), so blocking that name + # blocks the payload. + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + " __builtins__['open']('.env').read()\n" + ) + assert "use of '__builtins__'" in MathAnimate._scan_scene_code(code) + + +def test_blocks_getattr_reflection_bypass(): + # getattr-based attribute reflection is a classic denylist evasion; blocking + # the getattr name removes the primitive. + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + " cls = getattr(object(), '__class__')\n" + ) + assert "use of 'getattr'" in MathAnimate._scan_scene_code(code) + + +def test_blocks_aliased_dangerous_builtin(): + # Binding a blocked builtin to another name must still trip on the name use. + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + " f = open\n" + " f('.env')\n" + ) + assert "use of 'open'" in MathAnimate._scan_scene_code(code) + + +def test_blocks_sandbox_escape_dunders(): + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + " ().__class__.__bases__[0].__subclasses__()\n" + ) + violations = MathAnimate._scan_scene_code(code) + assert "dunder attribute access '.__class__'" in violations + assert "dunder attribute access '.__bases__'" in violations + assert "dunder attribute access '.__subclasses__'" in violations + + +def test_blocks_builtins_module_via_print_self(): + # Regression for the reported no-import bypass: print.__self__ is the + # builtins module, reachable without an import, a bare open/__builtins__/ + # getattr, or a blocked name. Blocking all reflection dunders closes it. + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + " print.__self__.open('.env').read()\n" + ) + assert "dunder attribute access '.__self__'" in MathAnimate._scan_scene_code(code) + + +def test_super_init_is_allowed(): + # A legitimate custom Mobject with super().__init__() must not be blocked — + # __init__ (and __name__) are the only permitted dunders. + code = ( + "from manim import *\n" + "class Widget(VGroup):\n" + " def __init__(self, **kwargs):\n" + " super().__init__(**kwargs)\n" + " self.add(Circle())\n" + "class S(Scene):\n" + " def construct(self):\n" + " self.add(Widget())\n" + ) + assert MathAnimate._scan_scene_code(code) == [] + + +def test_syntax_error_defers_to_manim(): + # A parse failure must not mask as a safety violation; Manim reports it. + assert MathAnimate._scan_scene_code("class S(Scene):\n def construct(self)\n") == [] + + +def test_execute_blocks_dangerous_code_before_running_manim(monkeypatch): + # Pretend manim is installed so execute() reaches the safety gate rather + # than short-circuiting on a missing binary. The scan must reject before any + # subprocess runs. + monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/manim") + + def boom(*a, **k): # subprocess must never be reached + raise AssertionError("subprocess.run should not be called for blocked code") + + monkeypatch.setattr("subprocess.run", boom) + + dangerous = ( + "from manim import *\n" + "import os\n" + "class S(Scene):\n" + " def construct(self):\n" + " print(os.environ)\n" + ) + result = MathAnimate().execute({"scene_code": dangerous}) + assert result.success is False + assert "safety scan" in result.error + assert "allow_unsafe_code" in result.error + + +def test_allow_unsafe_code_bypasses_scan(monkeypatch): + # With the opt-out, execution proceeds past the scan to Manim (which we stub + # to fail); the failure must NOT be the safety-scan message. + monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/manim") + + class FakeProc: + returncode = 1 + stderr = "manim ran" + stdout = "" + + monkeypatch.setattr("subprocess.run", lambda *a, **k: FakeProc()) + + dangerous = ( + "from manim import *\n" + "import os\n" + "class S(Scene):\n" + " def construct(self):\n" + " print(os.environ)\n" + ) + result = MathAnimate().execute({"scene_code": dangerous, "allow_unsafe_code": True}) + assert result.success is False + assert "safety scan" not in (result.error or "") diff --git a/tests/tools/test_remotion_diagnostics.py b/tests/tools/test_remotion_diagnostics.py new file mode 100644 index 00000000..79db4633 --- /dev/null +++ b/tests/tools/test_remotion_diagnostics.py @@ -0,0 +1,128 @@ +"""Tests for Remotion render debuggability in video_compose (issue #217). + +Two creator-facing gaps: + 1. A failed `npx remotion render` surfaced only "returned non-zero exit + status 1"; the useful Remotion diagnostics in stderr were dropped. + 2. There was no pass-through for Remotion's `--timeout`, so a slow headless + browser setup failed opaquely with no way to raise the limit. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.video.video_compose import VideoCompose # noqa: E402 + + +@pytest.fixture +def tool(monkeypatch): + monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/npx") + return VideoCompose() + + +def test_render_failure_surfaces_remotion_stderr_tail(tool, tmp_path, monkeypatch): + stderr = "some npm noise\nError: Delayed render timed out\nRemotion actual cause here" + + def fake_run_command(cmd, *a, **k): + raise subprocess.CalledProcessError(returncode=1, cmd=cmd, output="", stderr=stderr) + + monkeypatch.setattr(tool, "run_command", fake_run_command) + result = tool._remotion_render( + {"composition_data": {"cuts": []}, "output_path": str(tmp_path / "out.mp4")} + ) + + assert result.success is False + assert "exit 1" in result.error + assert "Remotion actual cause here" in result.error + + +def test_timeout_expired_gives_actionable_message(tool, tmp_path, monkeypatch): + def fake_run_command(cmd, *a, **k): + raise subprocess.TimeoutExpired(cmd=cmd, timeout=600) + + monkeypatch.setattr(tool, "run_command", fake_run_command) + result = tool._remotion_render( + {"composition_data": {"cuts": []}, "output_path": str(tmp_path / "out.mp4")} + ) + + assert result.success is False + assert "timed out" in result.error.lower() + assert "remotion_timeout_ms" in result.error + + +def test_remotion_timeout_ms_is_passed_through(tool, tmp_path, monkeypatch): + seen = {} + + def fake_run_command(cmd, *a, **k): + seen["cmd"] = cmd + seen["timeout"] = k.get("timeout") + return None # output file intentionally absent + + monkeypatch.setattr(tool, "run_command", fake_run_command) + tool._remotion_render( + { + "composition_data": {"cuts": []}, + "output_path": str(tmp_path / "out.mp4"), + "remotion_timeout_ms": 120000, + } + ) + + assert "--timeout=120000" in seen["cmd"] + # subprocess timeout widened past the 120s render budget so run_command + # does not kill Remotion before its own timeout fires. + assert seen["timeout"] >= 180 + + +def test_high_level_render_forwards_timeout_to_remotion(tool, tmp_path, monkeypatch): + # The gap in the first cut: execute(operation="render") -> _render() builds a + # fresh remotion_inputs dict, so the option must be forwarded there, not only + # on a direct _remotion_render() call. + captured = {} + monkeypatch.setattr(tool, "_pre_compose_validation", lambda *a, **k: None) + monkeypatch.setattr(tool, "_needs_remotion", lambda *a, **k: True) + + def fake_remotion_render(inputs): + captured.update(inputs) + from tools.base_tool import ToolResult + + return ToolResult(success=True, data={}, artifacts=[]) + + monkeypatch.setattr(tool, "_remotion_render", fake_remotion_render) + monkeypatch.setattr(tool, "_run_final_review", lambda *a, **k: {}) + + tool._render( + { + "edit_decisions": { + "render_runtime": "remotion", + "renderer_family": "explainer-data", + "cuts": [{"id": "c1", "source": "a1", "in_seconds": 0, "out_seconds": 2}], + }, + "asset_manifest": {"assets": [{"id": "a1", "path": "/tmp/a1.mp4"}]}, + "output_path": str(tmp_path / "out.mp4"), + "remotion_timeout_ms": 120000, + } + ) + + assert captured.get("remotion_timeout_ms") == 120000 + + +def test_no_timeout_flag_when_not_requested(tool, tmp_path, monkeypatch): + seen = {} + + def fake_run_command(cmd, *a, **k): + seen["cmd"] = cmd + seen["timeout"] = k.get("timeout") + return None + + monkeypatch.setattr(tool, "run_command", fake_run_command) + tool._remotion_render( + {"composition_data": {"cuts": []}, "output_path": str(tmp_path / "out.mp4")} + ) + + assert not any(str(c).startswith("--timeout") for c in seen["cmd"]) + assert seen["timeout"] == 600 diff --git a/tests/tools/test_scoring.py b/tests/tools/test_scoring.py new file mode 100644 index 00000000..0b2072a3 --- /dev/null +++ b/tests/tools/test_scoring.py @@ -0,0 +1,47 @@ +"""Regression tests for provider scoring tokenization.""" + +from __future__ import annotations + +from lib.scoring import _tokenize_text, score_provider +from tools.base_tool import ToolStatus + + +class _FakeVideoTool: + name = "fake-video" + + def get_info(self) -> dict[str, object]: + return { + "name": "fake-video", + "provider": "fake", + "best_for": ["cinematic video"], + "supports": { + "native_audio": True, + "multi_shot": True, + "camera_direction": True, + "lip_sync": True, + "cinematic_quality": True, + }, + "stability": "production", + "runtime": "api", + } + + def get_status(self) -> ToolStatus: + return ToolStatus.AVAILABLE + + def estimate_cost(self, inputs: dict[str, object]) -> float: + return 0.0 + + +def test_tokenize_text_strips_trailing_punctuation() -> None: + assert _tokenize_text("cinematic.") == ["cinematic"] + assert _tokenize_text("v1.5.") == ["v1.5"] + assert _tokenize_text("gpt-4.1") == ["gpt-4.1"] + + +def test_cinematic_bonus_ignores_adjacent_punctuation() -> None: + tool = _FakeVideoTool() + plain = score_provider(tool, {"asset_type": "video", "intent": "make it cinematic and fast"}) + punctuated = score_provider(tool, {"asset_type": "video", "intent": "make it cinematic, and fast"}) + + assert punctuated.task_fit == plain.task_fit + assert punctuated.output_quality == plain.output_quality diff --git a/tools/analysis/dashscope_asr.py b/tools/analysis/dashscope_asr.py new file mode 100644 index 00000000..36932eca --- /dev/null +++ b/tools/analysis/dashscope_asr.py @@ -0,0 +1,386 @@ +"""DashScope (Alibaba Cloud Bailian) ASR with word-level timestamps. + +Uses the DashScope-native async transcription endpoint with +X-DashScope-Async: enable header. The model qwen3-asr-flash-filetrans is the +ONLY DashScope path that returns word-level timestamps (the sync +qwen3-asr-flash via /chat/completions does not). + +Pattern: submit (POST) -> poll (GET /tasks/{task_id}) -> download +transcription_url -> parse transcripts[].sentences[].words[]. + +This tool replaces the broken `whisperx` slot for subtitle-aligned +transcription. Word timestamps are normalized from milliseconds to seconds. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +class DashscopeAsr(BaseTool): + name = "dashscope_asr" + version = "0.1.0" + tier = ToolTier.ANALYZE + capability = "analysis" + provider = "dashscope" + stability = ToolStability.EXPERIMENTAL + execution_mode = ExecutionMode.ASYNC + determinism = Determinism.DETERMINISTIC + runtime = ToolRuntime.API + + dependencies = [] + install_instructions = ( + "Set DASHSCOPE_API_KEY to your Alibaba Cloud DashScope API key.\n" + " Get one at https://dashscope.aliyun.com/" + ) + fallback = "transcriber" + fallback_tools = ["transcriber"] + agent_skills = ["dashscope"] + + capabilities = [ + "speech_to_text", + "word_timestamps", + "multilingual", + ] + supports = { + "word_timestamps": True, + "multilingual": True, + "offline": False, + } + best_for = [ + "word-level timestamp transcription for subtitle alignment", + "Mandarin and English speech recognition", + "replacing whisperx when word-level granularity is needed", + ] + not_good_for = [ + "real-time transcription", + "local/offline transcription", + ] + + input_schema = { + "type": "object", + "required": ["audio_url"], + "properties": { + "audio_url": { + "type": "string", + "description": ( + "Publicly accessible URL of the audio file to transcribe. " + "Must be reachable by DashScope servers — local paths " + "are not supported." + ), + }, + "model": { + "type": "string", + "enum": ["qwen3-asr-flash-filetrans"], + "default": "qwen3-asr-flash-filetrans", + }, + "language_hints": { + "type": "array", + "items": {"type": "string"}, + "default": ["zh", "en"], + "description": ( + "Language hints to improve accuracy. " + 'Examples: ["zh", "en", "ja"].' + ), + }, + "enable_words": { + "type": "boolean", + "default": True, + "description": ( + "Enable word-level timestamps. Required for subtitle " + "alignment." + ), + }, + "output_path": {"type": "string"}, + "poll_interval_seconds": { + "type": "number", + "default": 5.0, + "minimum": 1.0, + }, + "timeout_seconds": { + "type": "integer", + "default": 300, + "minimum": 30, + }, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=256, vram_mb=0, disk_mb=20, network_required=True + ) + retry_policy = RetryPolicy( + max_retries=2, + backoff_seconds=2.0, + retryable_errors=["timeout", "rate_limit"], + ) + idempotency_key_fields = ["audio_url", "model", "enable_words", "language_hints"] + side_effects = [ + "writes transcription JSON to output_path", + "calls DashScope (Alibaba Cloud) ASR API (async submit + poll)", + ] + user_visible_verification = [ + "Check transcription text for accuracy", + "Verify word-level timestamps before building subtitles", + ] + + SUBMIT_URL = ( + "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/" + "transcription" + ) + POLL_URL_TEMPLATE = ( + "https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}" + ) + + def get_status(self) -> ToolStatus: + if os.environ.get("DASHSCOPE_API_KEY"): + return ToolStatus.AVAILABLE + return ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + # DashScope ASR pricing is per-minute; check console for actual cost. + return 0.0 + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + api_key = os.environ.get("DASHSCOPE_API_KEY") + if not api_key: + return ToolResult( + success=False, + error="DASHSCOPE_API_KEY not set. " + self.install_instructions, + ) + + audio_url = inputs.get("audio_url", "").strip() + if not audio_url: + return ToolResult( + success=False, error="audio_url is required." + ) + if not self._is_public_url(audio_url): + return ToolResult( + success=False, + error=( + "audio_url must be a publicly accessible URL (http/https). " + "DashScope servers fetch the file; local paths are not " + "supported. Upload the audio to a public location first." + ), + ) + # DashScope ASR rejects http:// URLs with InvalidParameter.MalformedURL; + # upgrade to https:// before submitting. Note: signed OSS URLs with + # query params (Expires, Signature) may also be rejected — prefer clean + # public file URLs when possible. + if audio_url.startswith("http://"): + audio_url = "https://" + audio_url[len("http://"):] + inputs = {**inputs, "audio_url": audio_url} + + start = time.time() + try: + result = self._transcribe(inputs, api_key=api_key) + except Exception as exc: + return ToolResult( + success=False, + error=f"DashScope ASR failed: {self._safe_error(exc)}", + ) + + result.duration_seconds = round(time.time() - start, 2) + return result + + def _transcribe( + self, inputs: dict[str, Any], *, api_key: str + ) -> ToolResult: + import json + import requests + + payload = self._build_payload(inputs) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + } + + # Submit + submit_resp = requests.post( + self.SUBMIT_URL, headers=headers, json=payload, timeout=(10, 60) + ) + submit_data = self._json_or_raise(submit_resp) + self._raise_for_error(submit_resp.status_code, submit_data) + + task_id = submit_data.get("output", {}).get("task_id") + if not task_id: + raise RuntimeError( + "DashScope ASR submit succeeded but did not return " + "output.task_id" + ) + + # Poll + poll_data = self._poll_task( + requests_module=requests, + api_key=api_key, + task_id=task_id, + poll_interval=float(inputs.get("poll_interval_seconds", 5.0)), + timeout_seconds=int(inputs.get("timeout_seconds", 300)), + ) + + # qwen3-asr-flash-filetrans returns output.result.transcription_url + # (singular "result", NOT "results" array like paraformer-v2) + result = poll_data.get("output", {}).get("result", {}) + transcription_url = result.get("transcription_url") + if not transcription_url: + raise RuntimeError( + "DashScope ASR task succeeded but " + "result.transcription_url missing" + ) + + # Download transcription JSON + trans_resp = requests.get(transcription_url, timeout=120) + trans_resp.raise_for_status() + transcription = trans_resp.json() + + # Save full transcription + output_path = Path( + inputs.get("output_path", "dashscope_asr.json") + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(transcription, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + # Parse word-level timestamps (normalize ms -> seconds) + words = self._extract_words(transcription) + transcripts = transcription.get("transcripts", []) + + return ToolResult( + success=True, + data={ + "provider": "dashscope", + "model": payload["model"], + "audio_url": inputs["audio_url"], + "task_id": task_id, + "transcripts": transcripts, + "words": words, + "word_count": len(words), + "output": str(output_path), + }, + artifacts=[str(output_path)], + cost_usd=self.estimate_cost(inputs), + model=payload["model"], + ) + + def _build_payload(self, inputs: dict[str, Any]) -> dict[str, Any]: + return { + "model": inputs.get( + "model", "qwen3-asr-flash-filetrans" + ), + "input": { + "file_url": inputs["audio_url"], + }, + "parameters": { + "enable_words": bool(inputs.get("enable_words", True)), + "language_hints": inputs.get( + "language_hints", ["zh", "en"] + ), + }, + } + + def _poll_task( + self, + *, + requests_module: Any, + api_key: str, + task_id: str, + poll_interval: float, + timeout_seconds: int, + ) -> dict[str, Any]: + deadline = time.time() + timeout_seconds + headers = {"Authorization": f"Bearer {api_key}"} + while time.time() < deadline: + time.sleep(poll_interval) + resp = requests_module.get( + self.POLL_URL_TEMPLATE.format(task_id=task_id), + headers=headers, + timeout=(10, 60), + ) + data = self._json_or_raise(resp) + self._raise_for_error(resp.status_code, data) + status = data.get("output", {}).get("task_status") + if status == "SUCCEEDED": + return data + if status == "FAILED": + msg = data.get("output", {}).get( + "message", "unknown error" + ) + raise RuntimeError( + f"DashScope ASR task failed: {msg}" + ) + raise TimeoutError( + f"DashScope ASR task {task_id} did not finish within " + f"{timeout_seconds}s" + ) + + @staticmethod + def _is_public_url(url: str) -> bool: + return url.startswith("http://") or url.startswith("https://") + + @staticmethod + def _extract_words( + transcription: dict[str, Any] + ) -> list[dict[str, Any]]: + """Extract flat word list with timestamps normalized to seconds.""" + words: list[dict[str, Any]] = [] + for transcript in transcription.get("transcripts", []): + for sentence in transcript.get("sentences", []): + for word in sentence.get("words", []): + words.append( + { + "text": word.get("text", ""), + "begin_time_seconds": round( + word.get("begin_time", 0) / 1000.0, 3 + ), + "end_time_seconds": round( + word.get("end_time", 0) / 1000.0, 3 + ), + } + ) + return words + + @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 DashScope API: " + f"HTTP {response.status_code}" + ) from exc + + def _raise_for_error( + self, http_status: int, payload: dict[str, Any] + ) -> None: + if http_status < 400: + return + code = payload.get("code") + message = payload.get("message", "unknown error") + raise RuntimeError( + f"DashScope API error: HTTP {http_status}, " + f"code {code}: {message}" + ) + + @staticmethod + def _safe_error(exc: Exception) -> str: + return str(exc).replace( + os.environ.get("DASHSCOPE_API_KEY", ""), "[redacted]" + ) diff --git a/tools/audio/audio_mixer.py b/tools/audio/audio_mixer.py index dba08b0b..04a052e3 100644 --- a/tools/audio/audio_mixer.py +++ b/tools/audio/audio_mixer.py @@ -492,17 +492,22 @@ class AudioMixer(BaseTool): duck_enabled = ducking.get("enabled", True) if isinstance(ducking, dict) else bool(ducking) if duck_enabled and speech_tracks and music_tracks: - # Mix speech tracks together first + # Build ONE speech stream, then split it into two independent + # branches: one feeds the sidechain compressor as the ducking key, + # the other is mixed into the final output. A filtergraph label may + # only be consumed once, so reusing the same speech label for both + # the sidechain key and the output mix is invalid on stricter ffmpeg + # builds (e.g. the Linux ffmpeg on CI). asplit makes the fork explicit. speech_indices = list(range(len(speech_tracks))) speech_labels = "".join(f"[a{i}]" for i in speech_indices) if len(speech_tracks) > 1: filter_parts.append( - f"{speech_labels}amix=inputs={len(speech_tracks)}:duration=longest[speech_mix]" + f"{speech_labels}amix=inputs={len(speech_tracks)}:duration=longest[speech_all]" ) - speech_out = "[speech_mix]" else: - speech_out = f"[a{speech_indices[0]}]" + filter_parts.append(f"[a{speech_indices[0]}]acopy[speech_all]") + filter_parts.append("[speech_all]asplit=2[speech_key][speech_out]") # Mix music tracks together music_start = len(speech_tracks) @@ -517,42 +522,20 @@ class AudioMixer(BaseTool): else: music_in = f"[a{music_indices[0]}]" - # Apply sidechain ducking + # Apply sidechain ducking — music is compressed, [speech_key] is the key duck_params = ducking if isinstance(ducking, dict) else {} attack = duck_params.get("attack_ms", 200) / 1000 release = duck_params.get("release_ms", 500) / 1000 music_vol = duck_params.get("music_volume_during_speech", 0.15) filter_parts.append( - f"{music_in}{speech_out}sidechaincompress=" + f"{music_in}[speech_key]sidechaincompress=" f"threshold=0.02:ratio=9:attack={attack}:release={release}:" f"level_sc=1:mix=0.9[ducked_music];" f"[ducked_music]volume={music_vol * 3}[music_out]" ) - # Duplicate speech for final mix (sidechain consumes it as key) - filter_parts.append( - f"{speech_out}acopy[speech_dup]" if speech_out.startswith("[a") else "" - ) - # Re-mix speech path: we need speech audio in the output too - # Simpler approach: use amix on original speech and ducked music - # Reset: use a cleaner approach — amerge the speech mix and ducked music - # Actually, let's rebuild. The sidechain approach above uses speech as - # the key signal but doesn't consume it from the output chain. - # FFmpeg sidechaincompress: input 0 = audio to compress, input 1 = key signal - # So music is compressed, speech signal is the key. We need to mix them. - # Remove the last filter_part (the acopy that may be empty) - if filter_parts and filter_parts[-1] == "": - filter_parts.pop() - - # Build speech mix for output separately - if len(speech_tracks) > 1: - # speech_mix already exists, make a copy for output - filter_parts.append(f"{speech_labels}amix=inputs={len(speech_tracks)}:duration=longest[speech_out]") - else: - filter_parts.append(f"[a{speech_indices[0]}]acopy[speech_out]") - - # Final mix: speech_out + music_out + # Final mix: the other speech branch + ducked music mix_label = "[speech_out][music_out]amix=inputs=2:duration=longest[premix]" # Add SFX if present diff --git a/tools/audio/dashscope_tts.py b/tools/audio/dashscope_tts.py new file mode 100644 index 00000000..9eb2c2e6 --- /dev/null +++ b/tools/audio/dashscope_tts.py @@ -0,0 +1,243 @@ +"""DashScope (Alibaba Cloud Bailian) text-to-speech via Qwen-TTS models. + +Uses the DashScope-native multimodal-generation endpoint (same as image gen). +The response contains a temporary audio URL (WAV, valid ~24h) that must be +downloaded separately — unlike OpenAI TTS which returns raw audio bytes. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +class DashscopeTTS(BaseTool): + name = "dashscope_tts" + version = "0.1.0" + tier = ToolTier.VOICE + capability = "tts" + provider = "dashscope" + stability = ToolStability.EXPERIMENTAL + execution_mode = ExecutionMode.SYNC + determinism = Determinism.STOCHASTIC + runtime = ToolRuntime.API + + dependencies = [] + install_instructions = ( + "Set DASHSCOPE_API_KEY to your Alibaba Cloud DashScope API key.\n" + " Get one at https://dashscope.aliyun.com/" + ) + fallback = "piper_tts" + fallback_tools = [ + "doubao_tts", + "elevenlabs_tts", + "openai_tts", + "piper_tts", + ] + agent_skills = ["dashscope"] + + capabilities = [ + "text_to_speech", + "voice_selection", + "multilingual", + ] + supports = { + "voice_cloning": False, + "multilingual": True, + "offline": False, + "native_audio": True, + } + best_for = [ + "natural Mandarin and multilingual narration via Qwen-TTS", + "cost-effective TTS via Alibaba Cloud", + "Chinese-language voiceover production", + ] + not_good_for = [ + "fully offline production", + "voice clone matching", + ] + + input_schema = { + "type": "object", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "description": ( + "Text to convert to speech " + "(max 600 chars for qwen3-tts-flash)." + ), + }, + "model": { + "type": "string", + "enum": [ + "qwen3-tts-flash", + "qwen3-tts-instruct-flash", + "qwen-tts-2025-05-22", + ], + "default": "qwen3-tts-flash", + }, + "voice": { + "type": "string", + "default": "Cherry", + "description": ( + 'DashScope voice name. Examples: "Cherry", "Ethan", ' + '"Chelsie".' + ), + }, + "language_type": { + "type": "string", + "default": "Auto", + "enum": ["Auto", "Chinese", "English", "Japanese", "Korean"], + "description": "Language hint for the TTS model.", + }, + "instructions": { + "type": "string", + "description": ( + "Natural language delivery instructions " + "(only for qwen3-tts-instruct-flash)." + ), + }, + "output_path": {"type": "string"}, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=256, vram_mb=0, disk_mb=50, network_required=True + ) + retry_policy = RetryPolicy( + max_retries=2, retryable_errors=["rate_limit", "timeout"] + ) + idempotency_key_fields = ["text", "voice", "model", "language_type", "instructions"] + side_effects = [ + "writes audio file to output_path", + "calls DashScope (Alibaba Cloud) TTS API", + ] + user_visible_verification = [ + "Listen to generated audio for naturalness and pacing" + ] + + ENDPOINT = ( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/" + "multimodal-generation/generation" + ) + + def get_status(self) -> ToolStatus: + if os.environ.get("DASHSCOPE_API_KEY"): + return ToolStatus.AVAILABLE + return ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + # Conservative per-character estimate; DashScope bills by character. + return round(len(inputs.get("text", "")) * 0.000015, 4) + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + api_key = os.environ.get("DASHSCOPE_API_KEY") + if not api_key: + return ToolResult( + success=False, + error="DASHSCOPE_API_KEY not set. " + self.install_instructions, + ) + + import requests + + from tools.analysis.audio_probe import probe_duration + + start = time.time() + try: + payload = self._build_payload(inputs) + response = requests.post( + self.ENDPOINT, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=120, + ) + response.raise_for_status() + data = response.json() + + audio_info = data.get("output", {}).get("audio", {}) + audio_url = audio_info.get("url") + if not audio_url: + return ToolResult( + success=False, + error="DashScope TTS returned no audio URL", + ) + + # Download the audio from the temporary URL (valid ~24h). + download = requests.get(audio_url, timeout=120) + download.raise_for_status() + + output_path = Path( + inputs.get("output_path", "dashscope_tts.wav") + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(download.content) + + audio_duration = probe_duration(output_path) + usage = data.get("usage", {}) + + except Exception as e: + return ToolResult( + success=False, + error=f"DashScope TTS failed: {self._safe_error(e)}", + ) + + return ToolResult( + success=True, + data={ + "provider": "dashscope", + "model": payload["model"], + "voice": payload["input"]["voice"], + "language_type": payload["input"].get("language_type", "Auto"), + "text_length": len(inputs["text"]), + "audio_duration_seconds": ( + round(audio_duration, 2) if audio_duration else None + ), + "output": str(output_path), + "audio_url": audio_url, + "usage": usage, + }, + artifacts=[str(output_path)], + cost_usd=self.estimate_cost(inputs), + duration_seconds=round(time.time() - start, 2), + model=payload["model"], + ) + + def _build_payload(self, inputs: dict[str, Any]) -> dict[str, Any]: + input_data: dict[str, Any] = { + "text": inputs["text"], + "voice": inputs.get("voice", "Cherry"), + "language_type": inputs.get("language_type", "Auto"), + } + if inputs.get("instructions"): + input_data["instructions"] = inputs["instructions"] + input_data["optimize_instructions"] = True + + return { + "model": inputs.get("model", "qwen3-tts-flash"), + "input": input_data, + } + + @staticmethod + def _safe_error(exc: Exception) -> str: + return str(exc).replace( + os.environ.get("DASHSCOPE_API_KEY", ""), "[redacted]" + ) diff --git a/tools/audio/piper_tts.py b/tools/audio/piper_tts.py index 090fbd3a..cd44d255 100644 --- a/tools/audio/piper_tts.py +++ b/tools/audio/piper_tts.py @@ -98,11 +98,7 @@ class PiperTTS(BaseTool): def get_status(self) -> ToolStatus: if shutil.which("piper"): return ToolStatus.AVAILABLE - try: - import piper # noqa: F401 - return ToolStatus.AVAILABLE - except ImportError: - return ToolStatus.UNAVAILABLE + return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, Any]) -> float: return 0.0 diff --git a/tools/base_tool.py b/tools/base_tool.py index 50e6d249..1194e97d 100644 --- a/tools/base_tool.py +++ b/tools/base_tool.py @@ -6,6 +6,7 @@ interface for discovery, execution, cost estimation, and health reporting. from __future__ import annotations +import functools import hashlib import inspect import json @@ -13,6 +14,7 @@ import os import platform import subprocess import shutil +import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from enum import Enum @@ -136,9 +138,102 @@ class ToolResult: model: Optional[str] = None +import threading as _threading + +# Shared nesting counter for instrumented execute() calls (thread-local so +# parallel tool threads don't see each other's depth). +_EXECUTE_DEPTH = _threading.local() + + +def _instrument_execute(fn: Callable) -> Callable: + """Wrap a tool's execute() with Backlot event emission. + + Appends start/finish/error entries to the owning project's events.jsonl + when the call can be attributed to a project (explicit project_dir input + or any path input under projects/). Powers the board's live activity + ticker and per-scene generating states with zero agent involvement. + + Instrumentation is strictly non-fatal: any failure inside the event layer + is swallowed and the tool call proceeds untouched. + """ + if getattr(fn, "_backlot_instrumented", False): + return fn + + depth_state = _EXECUTE_DEPTH # shared across all tools (selector → provider) + + @functools.wraps(fn) + def wrapper(self, inputs: Any, *args: Any, **kwargs: Any): + # Event layer is fully optional: if it can't import, run untouched. + try: + from lib.events import emit_event, infer_project_dir + except Exception: + return fn(self, inputs, *args, **kwargs) + + tool_name = getattr(self, "name", "") or self.__class__.__name__ + scene_id = inputs.get("scene_id") if isinstance(inputs, dict) else None + output_path = inputs.get("output_path") if isinstance(inputs, dict) else None + # Nesting depth: selector tools delegate to provider tools' execute(). + # Both emit (the ticker wants the provider name too), but depth lets + # consumers dedupe — e.g. sum cost_usd only at depth 0. + depth = getattr(depth_state, "value", 0) + depth_state.value = depth + 1 + project_dir = infer_project_dir(inputs) + + base = { + "tool": tool_name, + "scene_id": scene_id, + "depth": depth if depth else None, + } + if project_dir is not None: + emit_event(project_dir, { + **base, "event": "start", + "output_path": str(output_path) if output_path else None, + }) + + started = time.monotonic() + try: + result = fn(self, inputs, *args, **kwargs) + except Exception as exc: + if project_dir is not None: + emit_event(project_dir, { + **base, "event": "error", + "error": str(exc)[:300], + "duration_s": round(time.monotonic() - started, 2), + }) + raise + finally: + depth_state.value = depth + + if project_dir is None: + # The tool may have created its own project dir during execute + # (first call of a run) — attribute the finish if possible. + project_dir = infer_project_dir(inputs) + if project_dir is not None: + cost = getattr(result, "cost_usd", None) + emit_event(project_dir, { + **base, "event": "finish", + "output_path": str(output_path) if output_path else None, + "success": getattr(result, "success", None), + # NOTE: 0.0 is meaningful (ran for free) — only None is dropped. + "cost_usd": cost if isinstance(cost, (int, float)) else None, + "duration_s": round(time.monotonic() - started, 2), + }) + return result + + wrapper._backlot_instrumented = True # type: ignore[attr-defined] + return wrapper + + class BaseTool(ABC): """Abstract base class for all OpenMontage tools.""" + def __init_subclass__(cls, **kwargs: Any) -> None: + """Auto-instrument every concrete execute() with Backlot events.""" + super().__init_subclass__(**kwargs) + impl = cls.__dict__.get("execute") + if impl is not None and not getattr(impl, "__isabstractmethod__", False): + cls.execute = _instrument_execute(impl) + # --- Identity (override in subclasses) --- name: str = "" version: str = "0.1.0" diff --git a/tools/graphics/dashscope_image.py b/tools/graphics/dashscope_image.py new file mode 100644 index 00000000..784b478c --- /dev/null +++ b/tools/graphics/dashscope_image.py @@ -0,0 +1,273 @@ +"""DashScope (Alibaba Cloud Bailian) image generation via Qwen-Image models. + +Uses the DashScope-native multimodal-generation endpoint (NOT OpenAI-compatible +mode, which only supports /chat/completions and /embeddings). The response +contains a temporary image URL (valid ~24h) that must be downloaded separately. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +class DashscopeImage(BaseTool): + name = "dashscope_image" + version = "0.1.0" + tier = ToolTier.GENERATE + capability = "image_generation" + provider = "dashscope" + stability = ToolStability.EXPERIMENTAL + execution_mode = ExecutionMode.SYNC + determinism = Determinism.STOCHASTIC + runtime = ToolRuntime.API + + dependencies = [] + install_instructions = ( + "Set DASHSCOPE_API_KEY to your Alibaba Cloud DashScope API key.\n" + " Get one at https://dashscope.aliyun.com/" + ) + fallback = "grok_image" + fallback_tools = ["grok_image", "openai_image", "flux_image", "recraft_image"] + agent_skills = ["dashscope"] + + capabilities = ["generate_image", "text_to_image"] + supports = { + "multiple_outputs": True, + "aspect_ratio": True, + "resolution": True, + "negative_prompt": True, + "seed": True, + } + best_for = [ + "high-quality image generation with Qwen-Image models", + "Chinese-language prompt understanding", + "cost-effective image generation via Alibaba Cloud", + ] + not_good_for = ["offline generation", "image editing (use grok_image edit mode)"] + + input_schema = { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": {"type": "string"}, + "model": { + "type": "string", + "enum": [ + "qwen-image-2.0-pro", + "qwen-image-max", + "wan2.7-image", + "z-image-turbo", + ], + "default": "qwen-image-2.0-pro", + }, + "size": { + "type": "string", + "default": "1024*1024", + "description": ( + 'Image size as "W*H" (asterisk separator, NOT "x"). ' + 'Examples: "1024*1024", "2048*2048", "2688*1536".' + ), + }, + "n": {"type": "integer", "default": 1, "minimum": 1, "maximum": 6}, + "negative_prompt": { + "type": "string", + "description": "Negative prompt (max 500 chars). Things to avoid in the image.", + }, + "prompt_extend": { + "type": "boolean", + "default": True, + "description": "Enable DashScope prompt auto-rewrite for better results.", + }, + "watermark": {"type": "boolean", "default": False}, + "seed": {"type": "integer", "minimum": 0, "maximum": 2147483647}, + "output_path": {"type": "string"}, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=100, network_required=True + ) + retry_policy = RetryPolicy( + max_retries=2, retryable_errors=["rate_limit", "timeout"] + ) + idempotency_key_fields = [ + "prompt", + "model", + "size", + "n", + "negative_prompt", + "seed", + "prompt_extend", + "watermark", + ] + side_effects = [ + "writes image file to output_path", + "calls DashScope (Alibaba Cloud) image generation API", + ] + user_visible_verification = [ + "Inspect generated image for relevance and quality" + ] + + ENDPOINT = ( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/" + "multimodal-generation/generation" + ) + + def get_status(self) -> ToolStatus: + if os.environ.get("DASHSCOPE_API_KEY"): + return ToolStatus.AVAILABLE + return ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + # Conservative per-image estimate; DashScope bills per image. + # Check the DashScope console for actual pricing. + n = int(inputs.get("n", 1)) + return n * 0.02 + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + api_key = os.environ.get("DASHSCOPE_API_KEY") + if not api_key: + return ToolResult( + success=False, + error="DASHSCOPE_API_KEY not set. " + self.install_instructions, + ) + + import requests + + start = time.time() + try: + payload = self._build_payload(inputs) + response = requests.post( + self.ENDPOINT, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=180, + ) + response.raise_for_status() + data = response.json() + + image_urls = self._extract_image_urls(data) + if not image_urls: + return ToolResult( + success=False, + error="DashScope returned no image URLs", + ) + + # DashScope bills per image and URLs expire ~24h; save every one. + output_paths = self._resolve_output_paths( + inputs.get("output_path", "dashscope_image.png"), + count=len(image_urls), + ) + for path, url in zip(output_paths, image_urls): + download = requests.get(url, timeout=120) + download.raise_for_status() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(download.content) + + usage = data.get("usage", {}) + n_generated = len(image_urls) + + except Exception as e: + return ToolResult( + success=False, + error=f"DashScope image generation failed: {self._safe_error(e)}", + ) + + return ToolResult( + success=True, + data={ + "provider": "dashscope", + "model": payload["model"], + "prompt": inputs["prompt"], + "size": payload["parameters"]["size"], + "output": str(output_paths[0]), + "outputs": [str(p) for p in output_paths], + "images_generated": n_generated, + "usage": usage, + }, + artifacts=[str(p) for p in output_paths], + cost_usd=self.estimate_cost(inputs), + duration_seconds=round(time.time() - start, 2), + model=payload["model"], + ) + + @staticmethod + def _extract_image_urls(data: dict[str, Any]) -> list[str]: + """Collect image URLs from every choice whose finish_reason is "stop". + + Per Qwen Cloud docs, a multi-output task is SUCCEEDED if at least one + image is generated; failed choices carry finish_reason != "stop" and + must be skipped to avoid downloading partial/empty results. + """ + urls: list[str] = [] + for choice in data.get("output", {}).get("choices", []): + if choice.get("finish_reason") != "stop": + continue + for item in choice.get("message", {}).get("content", []): + url = item.get("image") + if url: + urls.append(url) + return urls + + @staticmethod + def _resolve_output_paths(base: str, count: int) -> list[Path]: + """Derive distinct paths for `count` images. Single image keeps the + base path unchanged; multiple images insert an index before the + extension (foo.png -> foo_1.png, foo_2.png, ...).""" + base_path = Path(base) + if count <= 1: + return [base_path] + stem = base_path.stem + suffix = base_path.suffix + parent = base_path.parent + return [parent / f"{stem}_{i}{suffix}" for i in range(1, count + 1)] + + def _build_payload(self, inputs: dict[str, Any]) -> dict[str, Any]: + parameters: dict[str, Any] = { + "size": inputs.get("size", "1024*1024"), + "n": int(inputs.get("n", 1)), + "prompt_extend": bool(inputs.get("prompt_extend", True)), + "watermark": bool(inputs.get("watermark", False)), + } + if inputs.get("negative_prompt"): + parameters["negative_prompt"] = inputs["negative_prompt"] + if inputs.get("seed") is not None: + parameters["seed"] = int(inputs["seed"]) + + return { + "model": inputs.get("model", "qwen-image-2.0-pro"), + "input": { + "messages": [ + { + "role": "user", + "content": [{"text": inputs["prompt"]}], + } + ] + }, + "parameters": parameters, + } + + @staticmethod + def _safe_error(exc: Exception) -> str: + return str(exc).replace( + os.environ.get("DASHSCOPE_API_KEY", ""), "[redacted]" + ) diff --git a/tools/graphics/image_gen.py b/tools/graphics/image_gen.py index 933e0922..11e14884 100644 --- a/tools/graphics/image_gen.py +++ b/tools/graphics/image_gen.py @@ -7,7 +7,7 @@ pexels_image, pixabay_image). This file is kept for backwards compatibility and will be removed in a future release. -Supports cloud API providers (FLUX via fal.ai/Replicate, OpenAI DALL-E) +Supports cloud API providers (FLUX via fal.ai/Replicate, OpenAI GPT Image) and local Stable Diffusion via diffusers. Reports unavailable with install instructions when no provider is configured. """ @@ -43,12 +43,12 @@ class ImageGen(BaseTool): stability = ToolStability.EXPERIMENTAL execution_mode = ExecutionMode.SYNC determinism = Determinism.SEEDED - runtime = ToolRuntime.HYBRID # API (DALL-E/FLUX) or local (diffusers) + runtime = ToolRuntime.HYBRID # API (GPT Image/FLUX) or local (diffusers) dependencies = [] # checked dynamically based on provider install_instructions = ( "Set one of these environment variables:\n" - " OPENAI_API_KEY — for DALL-E 3\n" + " OPENAI_API_KEY — for GPT Image 2\n" " FAL_KEY — for FLUX via fal.ai\n" "Or install diffusers for local generation:\n" " pip install diffusers transformers accelerate torch" @@ -121,7 +121,7 @@ class ImageGen(BaseTool): def estimate_cost(self, inputs: dict[str, Any]) -> float: provider = inputs.get("provider") or self._detect_provider() if provider == "openai": - return 0.04 # DALL-E 3 standard + return 0.053 # gpt-image-2 medium at 1024x1024 (call uses auto quality) if provider == "flux": return 0.03 return 0.0 # local @@ -159,14 +159,14 @@ class ImageGen(BaseTool): client = OpenAI() prompt = inputs["prompt"] size = f"{inputs.get('width', 1024)}x{inputs.get('height', 1024)}" - model = inputs.get("model", "dall-e-3") + model = inputs.get("model", "gpt-image-2") + # GPT image models don't accept response_format; they always return b64 response = client.images.generate( model=model, prompt=prompt, size=size, n=1, - response_format="b64_json", ) image_data = base64.b64decode(response.data[0].b64_json) diff --git a/tools/graphics/math_animate.py b/tools/graphics/math_animate.py index 61c635b6..f7c55b1b 100644 --- a/tools/graphics/math_animate.py +++ b/tools/graphics/math_animate.py @@ -6,6 +6,7 @@ using the Manim Community Edition engine. Free, local, no API key required. from __future__ import annotations +import ast import os import shutil import subprocess @@ -28,6 +29,48 @@ from tools.base_tool import ( ) +# --- Safety: caller-supplied scene_code is a local code-execution boundary --- +# math_animate runs Manim on Python supplied by the caller (often an LLM or +# prompt-influenced reference material). That is arbitrary local code execution +# (see issue #219). The static scan below is defense-in-depth: it blocks the +# constructs an attack needs — reading secrets/SSH material, opening network +# connections, spawning subprocesses — while leaving genuine math/animation +# scenes untouched. It is NOT a security sandbox: a determined attacker can +# evade a static denylist, so it is paired with an explicit `allow_unsafe_code` +# opt-out and a tool contract that names the boundary. A passing scan is not +# proof that code is safe to run. +_BLOCKED_IMPORTS = frozenset({ + "os", "sys", "subprocess", "socket", "shutil", "requests", "urllib", + "http", "ftplib", "smtplib", "telnetlib", "ctypes", "pickle", "marshal", + "importlib", "builtins", "multiprocessing", "threading", "pty", "glob", + "resource", "signal", "tempfile", "webbrowser", "pathlib", +}) +# Dangerous identifiers blocked wherever they appear as a bare name — not just +# as a direct call. This catches indirection like `__builtins__['open']`, +# `f = open`, or `getattr(x, '__class__')` that a call-target-only or +# attribute-only check would miss. +_BLOCKED_NAMES = frozenset({ + "eval", "exec", "compile", "__import__", "open", "input", "breakpoint", + "__builtins__", "__loader__", "globals", "locals", "vars", + "getattr", "setattr", "delattr", +}) +# Reflection via dunder attributes is the general escape hatch: `().__class__`, +# `print.__self__` (the builtins module), `x.__globals__`, `f.__reduce__`, etc. +# Enumerating dangerous dunders one by one is whack-a-mole, so block ALL dunder +# *attribute access* and allow only a tiny set that legitimate scenes use +# (`super().__init__(...)`, occasional `Type.__name__`). A dunder is any name +# that starts and ends with double underscores. +_ALLOWED_DUNDER_ATTRS = frozenset({"__init__", "__name__"}) + + +def _is_blocked_dunder(attr: str) -> bool: + return ( + attr.startswith("__") + and attr.endswith("__") + and attr not in _ALLOWED_DUNDER_ATTRS + ) + + # Quality presets mapping to Manim CLI flags QUALITY_PRESETS = { "low": {"flag": "-ql", "resolution": "854x480", "fps": 15}, @@ -76,7 +119,20 @@ class MathAnimate(BaseTool): "description": ( "Python code defining a Manim scene. Must contain a class " "inheriting from Scene with a construct() method. " - "Import 'from manim import *' is auto-added if missing." + "Import 'from manim import *' is auto-added if missing. " + "SECURITY: this code is EXECUTED on the host by Manim. It is " + "scanned for dangerous constructs (system/network/subprocess " + "access) and rejected by default; treat scene_code as trusted " + "input only." + ), + }, + "allow_unsafe_code": { + "type": "boolean", + "default": False, + "description": ( + "Bypass the scene_code safety scan. Only set this for code " + "you fully trust — it permits arbitrary local code execution " + "(filesystem, network, subprocess). See issue #219." ), }, "scene_name": { @@ -117,7 +173,13 @@ class MathAnimate(BaseTool): ) retry_policy = RetryPolicy(max_retries=1, retryable_errors=["timeout"]) idempotency_key_fields = ["scene_code", "scene_name", "quality"] - side_effects = ["writes video/image file to output_path", "creates temp files"] + side_effects = [ + "EXECUTES caller-supplied Python (Manim scene_code) on the host — this " + "is a local code-execution boundary; scene_code is scanned and rejected " + "by default unless allow_unsafe_code=true (see issue #219)", + "writes video/image file to output_path", + "creates temp files", + ] user_visible_verification = [ "Watch the animation for correctness and visual quality", "Verify math formulas render correctly (requires LaTeX)", @@ -160,6 +222,48 @@ class MathAnimate(BaseTool): result.duration_seconds = round(time.time() - start, 2) return result + @staticmethod + def _scan_scene_code(code: str) -> list[str]: + """Static safety scan of caller-supplied Manim scene code (issue #219). + + Returns a de-duplicated list of disallowed constructs (dangerous + imports, builtins, and sandbox-escape dunders). Empty list means the + scan found nothing to block — which is NOT a guarantee the code is safe. + A syntax error is left for Manim to report, so it returns no violations. + """ + try: + tree = ast.parse(code) + except SyntaxError: + return [] + + violations: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + root = alias.name.split(".")[0] + if root in _BLOCKED_IMPORTS: + violations.append(f"import '{alias.name}'") + elif isinstance(node, ast.ImportFrom): + root = (node.module or "").split(".")[0] + if root in _BLOCKED_IMPORTS: + violations.append(f"from '{node.module}' import ...") + elif isinstance(node, ast.Name): + # Blocks direct calls (eval(...)) and indirection alike: + # `__builtins__['open']`, `f = open`, `getattr(o, '__class__')`. + if node.id in _BLOCKED_NAMES: + violations.append(f"use of '{node.id}'") + elif isinstance(node, ast.Attribute): + if _is_blocked_dunder(node.attr): + violations.append(f"dunder attribute access '.{node.attr}'") + + seen: set[str] = set() + deduped: list[str] = [] + for v in violations: + if v not in seen: + seen.add(v) + deduped.append(v) + return deduped + def _render(self, inputs: dict[str, Any]) -> ToolResult: scene_code = inputs["scene_code"] scene_name = inputs.get("scene_name") @@ -174,6 +278,23 @@ class MathAnimate(BaseTool): if "from manim import" not in scene_code: scene_code = "from manim import *\n\n" + scene_code + # Safety gate: scene_code is executed on the host by Manim. Reject + # dangerous constructs unless the caller explicitly opts out. (issue #219) + if not inputs.get("allow_unsafe_code", False): + violations = self._scan_scene_code(scene_code) + if violations: + return ToolResult( + success=False, + error=( + "scene_code blocked by the math_animate safety scan. This " + "tool executes caller-supplied Python on the host; the " + "following constructs are disallowed by default:\n - " + + "\n - ".join(violations) + + "\nIf you fully trust this code and require them, pass " + "allow_unsafe_code=true. See issue #219." + ), + ) + # Auto-detect scene name if not provided if not scene_name: scene_name = self._detect_scene_name(scene_code) diff --git a/tools/graphics/openai_image.py b/tools/graphics/openai_image.py index 73bc2d95..cabf2c1e 100644 --- a/tools/graphics/openai_image.py +++ b/tools/graphics/openai_image.py @@ -1,4 +1,4 @@ -"""OpenAI GPT Image generation (gpt-image-1 / DALL-E 3).""" +"""OpenAI GPT Image generation (gpt-image-2).""" from __future__ import annotations @@ -60,20 +60,17 @@ class OpenAIImage(BaseTool): "prompt": {"type": "string"}, "model": { "type": "string", - "enum": ["gpt-image-1", "dall-e-3"], - "default": "gpt-image-1", + "enum": ["gpt-image-2"], + "default": "gpt-image-2", }, "size": { "type": "string", - "enum": [ - "1024x1024", "1536x1024", "1024x1536", "auto", - "1024x1792", "1792x1024", # dall-e-3 only - ], + "enum": ["1024x1024", "1536x1024", "1024x1536", "auto"], "default": "1024x1024", }, "quality": { "type": "string", - "enum": ["low", "medium", "high", "auto", "standard", "hd"], + "enum": ["low", "medium", "high", "auto"], "default": "high", }, "output_format": { @@ -100,15 +97,12 @@ class OpenAIImage(BaseTool): return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, Any]) -> float: - model = inputs.get("model", "gpt-image-1") + # gpt-image-2 per-image pricing at 1024x1024 (non-square sizes run + # slightly cheaper): https://developers.openai.com/api/docs/guides/image-generation quality = inputs.get("quality", "high") n = inputs.get("n", 1) - if model == "gpt-image-1": - cost_map = {"low": 0.011, "medium": 0.042, "high": 0.167, "auto": 0.042} - return cost_map.get(quality, 0.042) * n - # dall-e-3 fallback pricing - quality_map = {"standard": 0.04, "hd": 0.08} - return quality_map.get(quality, 0.04) * n + cost_map = {"low": 0.006, "medium": 0.053, "high": 0.211, "auto": 0.053} + return cost_map.get(quality, 0.053) * n def execute(self, inputs: dict[str, Any]) -> ToolResult: if not os.environ.get("OPENAI_API_KEY"): @@ -121,36 +115,22 @@ class OpenAIImage(BaseTool): start = time.time() client = OpenAI() - model = inputs.get("model", "gpt-image-1") + model = inputs.get("model", "gpt-image-2") prompt = inputs["prompt"] size = inputs.get("size", "1024x1024") n = inputs.get("n", 1) try: - if model == "gpt-image-1": - quality = inputs.get("quality", "high") - output_format = inputs.get("output_format", "png") - response = client.images.generate( - model=model, - prompt=prompt, - size=size, - quality=quality, - output_format=output_format, - n=n, - ) - else: - # dall-e-3 path - quality = inputs.get("quality", "standard") - if quality in ("low", "medium", "high", "auto"): - quality = "standard" # map to dall-e-3 quality options - response = client.images.generate( - model=model, - prompt=prompt, - size=size, - quality=quality, - n=1, # dall-e-3 only supports n=1 - response_format="b64_json", - ) + quality = inputs.get("quality", "high") + output_format = inputs.get("output_format", "png") + response = client.images.generate( + model=model, + prompt=prompt, + size=size, + quality=quality, + output_format=output_format, + n=n, + ) image_data = base64.b64decode(response.data[0].b64_json) ext = inputs.get("output_format", "png") diff --git a/tools/publishers/export_bundle.py b/tools/publishers/export_bundle.py new file mode 100644 index 00000000..43cd21ef --- /dev/null +++ b/tools/publishers/export_bundle.py @@ -0,0 +1,307 @@ +"""Local export bundler — the first PUBLISH-tier tool. + +Every pipeline ends in a `publish` stage that produces a `publish_log` artifact, +but `tools/publishers/` shipped empty, so the mechanical packaging (copying the +render, writing metadata files, laying out the export directory, and emitting a +schema-valid `publish_log`) had to be hand-rolled by the agent each time. + +This tool does that packaging deterministically and locally — no external +account, no upload, no cost. It takes the final render path plus the SEO +metadata the publish-director skill prepares and writes a self-contained export +bundle a creator can hand to any platform, returning a validated `publish_log` +entry with `status: "exported"`. + +A networked publisher (e.g. a YouTube uploader) can be added later as a separate +`provider` under the same `publish` capability. +""" + +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +class ExportBundle(BaseTool): + name = "export_bundle" + version = "0.1.0" + tier = ToolTier.PUBLISH + capability = "publish" + provider = "local" + stability = ToolStability.BETA + execution_mode = ExecutionMode.SYNC + determinism = Determinism.DETERMINISTIC + runtime = ToolRuntime.LOCAL + + dependencies = [] # pure filesystem packaging + install_instructions = "No setup required — runs locally with the Python standard library." + + agent_skills = [] + + capabilities = ["package_export", "write_publish_log"] + supports = { + "local_offline": True, + "free": True, + "uploads": False, + } + best_for = [ + "packaging a finished render for hand-off to any platform", + "producing a schema-valid publish_log without an external account", + "offline / no-API-key publishing", + ] + not_good_for = [ + "uploading directly to YouTube/TikTok/etc. (no network publish)", + "generating SEO metadata or thumbnails (the publish-director prepares those)", + ] + + input_schema = { + "type": "object", + "required": ["video_path", "title"], + "properties": { + "video_path": { + "type": "string", + "description": "Path to the final rendered video (from render_report.outputs[].path).", + }, + "title": {"type": "string", "description": "Video title / SEO title."}, + "project_name": { + "type": "string", + "description": "Project name; used for the export folder. Defaults to the video's parent-of-parent dir name.", + }, + "export_dir": { + "type": "string", + "description": "Override the export root. Defaults to 'exports/'.", + }, + "description": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "hashtags": {"type": "array", "items": {"type": "string"}}, + "chapters": { + "type": "array", + "items": { + "type": "object", + "description": "Either {start_seconds, title} or {time, label}.", + }, + }, + "subtitles_path": {"type": "string"}, + "thumbnail_path": {"type": "string"}, + "thumbnail_concept": { + "type": "object", + "description": "Thumbnail concept JSON when no rendered thumbnail exists.", + }, + "platform": { + "type": "string", + "description": "Target platform label for the publish_log entry. Defaults to 'local'.", + }, + "visibility": {"type": "string", "enum": ["public", "private", "unlisted"]}, + "timestamp": { + "type": "string", + "description": "Override the ISO-8601 timestamp (mainly for deterministic tests).", + }, + }, + } + output_schema = { + "type": "object", + "properties": { + "publish_log": {"type": "object"}, + "export_path": {"type": "string"}, + "files_written": {"type": "array", "items": {"type": "string"}}, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=0, network_required=False + ) + side_effects = ["writes an export bundle directory to disk"] + user_visible_verification = [ + "Open the export folder and confirm the video, metadata, and chapters are present and correct", + ] + + # ---- Helpers ---- + + @staticmethod + def _format_chapter_time(seconds: float) -> str: + seconds = int(round(seconds)) + h, rem = divmod(seconds, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h}:{m:02d}:{s:02d}" + return f"{m}:{s:02d}" + + def _chapter_lines(self, chapters: list[dict[str, Any]]) -> list[str]: + lines: list[str] = [] + for ch in chapters: + label = ch.get("title") or ch.get("label") or "" + if "start_seconds" in ch or "time_seconds" in ch: + ts = self._format_chapter_time(ch.get("start_seconds", ch.get("time_seconds", 0))) + elif "time" in ch: + ts = str(ch["time"]) + else: + ts = "0:00" + lines.append(f"{ts} - {label}".rstrip(" -")) + return lines + + # ---- Execution ---- + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + video_path = Path(inputs["video_path"]).expanduser() + if not video_path.is_file(): + return ToolResult(success=False, error=f"video_path not found: {video_path}") + + title = inputs["title"] + project_name = inputs.get("project_name") or self._infer_project_name(video_path) + + # Explicitly-provided optional assets must exist — silently dropping them + # would ship a publish package missing part of an approved deliverable. + for key in ("subtitles_path", "thumbnail_path"): + val = inputs.get(key) + if val and not Path(val).expanduser().is_file(): + return ToolResult(success=False, error=f"{key} provided but not found: {val}") + + export_root = ( + Path(inputs["export_dir"]).expanduser() + if inputs.get("export_dir") + else self._default_export_dir(video_path, project_name) + ) + + video_dir = export_root / "video" + meta_dir = export_root / "metadata" + thumb_dir = export_root / "thumbnails" + for d in (video_dir, meta_dir, thumb_dir): + d.mkdir(parents=True, exist_ok=True) + + files_written: list[str] = [] + + # Video + out_video = video_dir / f"output{video_path.suffix or '.mp4'}" + shutil.copy2(video_path, out_video) + files_written.append(str(out_video)) + + # Subtitles (optional) + subs_in = inputs.get("subtitles_path") + if subs_in: + subs_in = Path(subs_in).expanduser() + if subs_in.is_file(): + out_subs = video_dir / f"subtitles{subs_in.suffix or '.srt'}" + shutil.copy2(subs_in, out_subs) + files_written.append(str(out_subs)) + + description = inputs.get("description", "") + tags = inputs.get("tags", []) or [] + hashtags = inputs.get("hashtags", []) or [] + chapters = inputs.get("chapters", []) or [] + chapter_lines = self._chapter_lines(chapters) + + # metadata.json + metadata = { + "title": title, + "description": description, + "tags": tags, + "hashtags": hashtags, + "chapters": chapters, + } + meta_json = meta_dir / "metadata.json" + meta_json.write_text(json.dumps(metadata, indent=2), encoding="utf-8") + files_written.append(str(meta_json)) + + # description.txt (description + chapters appended, ready to paste) + desc_parts = [description] if description else [] + if chapter_lines: + desc_parts.append("\n".join(chapter_lines)) + desc_txt = meta_dir / "description.txt" + desc_txt.write_text("\n\n".join(desc_parts) + ("\n" if desc_parts else ""), encoding="utf-8") + files_written.append(str(desc_txt)) + + # tags.txt (one per line) + if tags: + tags_txt = meta_dir / "tags.txt" + tags_txt.write_text("\n".join(tags) + "\n", encoding="utf-8") + files_written.append(str(tags_txt)) + + # chapters.txt + if chapter_lines: + chapters_txt = meta_dir / "chapters.txt" + chapters_txt.write_text("\n".join(chapter_lines) + "\n", encoding="utf-8") + files_written.append(str(chapters_txt)) + + # Thumbnail: real image if given, else concept JSON + thumb_in = inputs.get("thumbnail_path") + if thumb_in and Path(thumb_in).expanduser().is_file(): + thumb_in = Path(thumb_in).expanduser() + out_thumb = thumb_dir / f"thumbnail{thumb_in.suffix or '.png'}" + shutil.copy2(thumb_in, out_thumb) + files_written.append(str(out_thumb)) + elif inputs.get("thumbnail_concept"): + concept = thumb_dir / "concept.json" + concept.write_text(json.dumps(inputs["thumbnail_concept"], indent=2), encoding="utf-8") + files_written.append(str(concept)) + + timestamp = inputs.get("timestamp") or datetime.now(timezone.utc).isoformat() + entry: dict[str, Any] = { + "platform": inputs.get("platform", "local"), + "status": "exported", + "export_path": str(export_root), + "timestamp": timestamp, + "metadata_used": { + "title": title, + "description": description, + "hashtags": hashtags, + "chapters": chapters, + }, + } + if inputs.get("visibility"): + entry["visibility"] = inputs["visibility"] + + publish_log = {"version": "1.0", "entries": [entry]} + + # Validate against the canonical schema so a bad entry fails here, not at checkpoint. + try: + from schemas.artifacts import validate_artifact + + validate_artifact("publish_log", publish_log) + except Exception as exc: # pragma: no cover - defensive + return ToolResult(success=False, error=f"publish_log failed schema validation: {exc}") + + return ToolResult( + success=True, + data={ + "publish_log": publish_log, + "export_path": str(export_root), + "files_written": files_written, + }, + artifacts=[str(out_video)], + ) + + @staticmethod + def _default_export_dir(video_path: Path, project_name: str) -> Path: + """Keep run output inside the project workspace. + + When the render lives at ``projects//renders/...`` (the OpenMontage + convention), default the bundle to ``projects//exports/`` alongside + ``artifacts/``, ``assets/`` and ``renders/``. Otherwise fall back to a + top-level ``exports//``. + """ + resolved = video_path.resolve() + if resolved.parent.name == "renders": + return resolved.parent.parent / "exports" + return Path("exports") / project_name + + @staticmethod + def _infer_project_name(video_path: Path) -> str: + # projects//renders/final.mp4 -> ; fall back to the file stem. + parents = video_path.resolve().parents + if len(parents) >= 2: + return parents[1].name + return video_path.stem diff --git a/tools/video/direct_clip_search.py b/tools/video/direct_clip_search.py index 53a118c5..45b9a782 100644 --- a/tools/video/direct_clip_search.py +++ b/tools/video/direct_clip_search.py @@ -33,6 +33,7 @@ No CLIP model. No embeddings. No corpus index. Just files on disk. """ from __future__ import annotations +from contextlib import contextmanager import subprocess import time import urllib.parse @@ -53,6 +54,10 @@ from tools.base_tool import ( ) +class _DeadlineExceeded(TimeoutError): + """Raised when the direct-clip-search wall-clock deadline is exhausted.""" + + class DirectClipSearch(BaseTool): name = "direct_clip_search" version = "0.1.0" @@ -178,6 +183,16 @@ class DirectClipSearch(BaseTool): "default": True, "description": "Skip download if a file with the same clip_id already exists.", }, + "timeout_seconds": { + "type": "number", + "default": 600, + "minimum": 1, + "description": ( + "Overall wall-clock deadline for search, download, and thumbnail " + "work. Defaults to 10 minutes. On timeout, returns partial progress " + "instead of relying on an external process interrupt." + ), + }, }, } @@ -245,6 +260,8 @@ class DirectClipSearch(BaseTool): clips_per_query = int(inputs.get("clips_per_query", 3)) extract_thumbs = bool(inputs.get("extract_thumbnails", True)) skip_existing = bool(inputs.get("skip_existing", True)) + timeout_seconds = float(inputs.get("timeout_seconds", 600)) + deadline = start + timeout_seconds clips_dir = output_dir / "clips" thumbs_dir = output_dir / "thumbnails" @@ -295,9 +312,53 @@ class DirectClipSearch(BaseTool): errors: list[dict] = [] skipped = 0 per_source_counts: dict[str, int] = {s.name: 0 for s in sources} + queries_started = 0 + + def timeout_result( + *, + phase: str, + query: str = "", + source: str = "", + clip_id: str = "", + ) -> ToolResult: + elapsed = time.time() - start + return ToolResult( + success=False, + error=( + f"Direct clip search timed out after {timeout_seconds:.1f}s " + f"during {phase}." + ), + data={ + "timed_out": True, + "phase": phase, + "query": query, + "source": source, + "clip_id": clip_id, + "output_dir": str(output_dir), + "clips_downloaded": len([d for d in downloaded if not d.get("skipped_existing")]), + "clips_reused": skipped, + "total_clips": len(downloaded), + "per_source_counts": per_source_counts, + "queries_run": queries_started, + "resolved_sources": [s.name for s in sources], + "clips": downloaded, + "errors": errors[:25], + "elapsed_seconds": round(elapsed, 2), + "timeout_seconds": timeout_seconds, + }, + cost_usd=0.0, + duration_seconds=round(elapsed, 2), + ) + + def timed_out() -> bool: + return time.time() >= deadline for q_spec in queries: + if timed_out(): + return timeout_result(phase="query", query=q_spec.get("query", "")) + query = q_spec["query"] + queries_started += 1 slot_id = q_spec.get("slot_id", "") kind = q_spec.get("kind", "video") collected_for_query = 0 @@ -312,11 +373,17 @@ class DirectClipSearch(BaseTool): ) for src in sources: + if timed_out(): + return timeout_result(phase="search", query=query, source=src.name) + if collected_for_query >= clips_per_query: break try: - candidates = src.search(query, filters) + with _requests_deadline(deadline): + candidates = src.search(query, filters) + except _DeadlineExceeded: + return timeout_result(phase="search", query=query, source=src.name) except Exception as e: errors.append({ "phase": "search", @@ -327,6 +394,14 @@ class DirectClipSearch(BaseTool): continue for cand in candidates: + if timed_out(): + return timeout_result( + phase="download", + query=query, + source=src.name, + clip_id=cand.clip_id, + ) + if collected_for_query >= clips_per_query: break @@ -362,7 +437,15 @@ class DirectClipSearch(BaseTool): # Download try: - src.download(cand, clip_path) + with _requests_deadline(deadline): + src.download(cand, clip_path) + except _DeadlineExceeded: + return timeout_result( + phase="download", + query=query, + source=src.name, + clip_id=clip_id, + ) except Exception as e: errors.append({ "phase": "download", @@ -386,21 +469,7 @@ class DirectClipSearch(BaseTool): pass continue - # Extract thumbnail - thumb_path_str = "" - if extract_thumbs and cand.kind == "video": - thumb_path = thumbs_dir / f"{clip_id}.jpg" - try: - _extract_mid_thumbnail(clip_path, thumb_path) - if thumb_path.exists(): - thumb_path_str = str(thumb_path) - except Exception: - pass # thumbnail failure is non-fatal - - per_source_counts[src.name] = per_source_counts.get(src.name, 0) + 1 - collected_for_query += 1 - - downloaded.append({ + downloaded_record = { "clip_id": clip_id, "source": cand.source, "source_id": cand.source_id, @@ -409,7 +478,7 @@ class DirectClipSearch(BaseTool): "slot_id": slot_id, "kind": cand.kind, "path": str(clip_path), - "thumbnail": thumb_path_str, + "thumbnail": "", "duration": cand.duration, "width": cand.width, "height": cand.height, @@ -417,7 +486,38 @@ class DirectClipSearch(BaseTool): "license": cand.license, "source_tags": cand.source_tags, "skipped_existing": False, - }) + } + downloaded.append(downloaded_record) + per_source_counts[src.name] = per_source_counts.get(src.name, 0) + 1 + collected_for_query += 1 + + # Extract thumbnail + if extract_thumbs and cand.kind == "video": + if timed_out(): + return timeout_result( + phase="thumbnail", + query=query, + source=src.name, + clip_id=clip_id, + ) + thumb_path = thumbs_dir / f"{clip_id}.jpg" + try: + _extract_mid_thumbnail( + clip_path, + thumb_path, + timeout_seconds=remaining_seconds(deadline), + ) + if thumb_path.exists(): + downloaded_record["thumbnail"] = str(thumb_path) + except _DeadlineExceeded: + return timeout_result( + phase="thumbnail", + query=query, + source=src.name, + clip_id=clip_id, + ) + except Exception: + pass # thumbnail failure is non-fatal elapsed = time.time() - start @@ -429,7 +529,7 @@ class DirectClipSearch(BaseTool): "clips_reused": skipped, "total_clips": len(downloaded), "per_source_counts": per_source_counts, - "queries_run": len(queries), + "queries_run": queries_started, "resolved_sources": [s.name for s in sources], "clips": downloaded, "errors": errors[:25], @@ -462,7 +562,64 @@ def _guess_ext(cand) -> str: return ".mp4" if cand.kind == "video" else ".jpg" -def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: +def remaining_seconds(deadline: float) -> float: + remaining = deadline - time.time() + if remaining <= 0: + raise _DeadlineExceeded("direct_clip_search deadline exceeded") + return remaining + + +def _clamp_timeout(timeout: Any, remaining: float) -> Any: + if timeout is None: + return remaining + if isinstance(timeout, tuple): + return tuple(min(float(part), remaining) for part in timeout) + try: + return min(float(timeout), remaining) + except (TypeError, ValueError): + return remaining + + +@contextmanager +def _requests_deadline(deadline: float): + """Clamp adapter requests calls to the direct-search deadline. + + Stock-source adapters are intentionally simple and call `requests.get` + directly. Keeping the deadline wrapper here avoids widening every adapter + method signature while still preventing streaming downloads from running + past the tool-level budget. + """ + import requests + + original_get = requests.get + + def get_with_deadline(*args, **kwargs): + remaining = remaining_seconds(deadline) + kwargs["timeout"] = _clamp_timeout(kwargs.get("timeout"), remaining) + response = original_get(*args, **kwargs) + original_iter_content = getattr(response, "iter_content", None) + if callable(original_iter_content): + def iter_content_with_deadline(*iter_args, **iter_kwargs): + for chunk in original_iter_content(*iter_args, **iter_kwargs): + remaining_seconds(deadline) + yield chunk + + response.iter_content = iter_content_with_deadline + return response + + requests.get = get_with_deadline + try: + yield + finally: + requests.get = original_get + + +def _extract_mid_thumbnail( + video_path: Path, + thumb_path: Path, + *, + timeout_seconds: float = 15, +) -> None: """Extract a single frame from the middle of the video via ffmpeg. This is deliberately simple — one frame, no CLIP, no motion score. @@ -470,6 +627,7 @@ def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: clip is a good match. """ thumb_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.time() + timeout_seconds # Probe duration first probe_cmd = [ @@ -479,8 +637,9 @@ def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: str(video_path), ] try: + probe_timeout = min(10, remaining_seconds(deadline)) result = subprocess.run( - probe_cmd, capture_output=True, text=True, timeout=10 + probe_cmd, capture_output=True, text=True, timeout=probe_timeout ) duration = float(result.stdout.strip() or "0") except (ValueError, subprocess.TimeoutExpired, FileNotFoundError): @@ -497,7 +656,8 @@ def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: "-q:v", "3", str(thumb_path), ] + extract_timeout = min(15, remaining_seconds(deadline)) subprocess.run( - extract_cmd, capture_output=True, timeout=15, + extract_cmd, capture_output=True, timeout=extract_timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index c2aa7e38..dbbf7401 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -17,9 +17,11 @@ Routing is driven by `edit_decisions.render_runtime` (locked at proposal): Authoring mode is orthogonal to runtime. Setting `edit_decisions.composition_mode = "atelier"` (or `renderer_family="bespoke"`) -routes to a hand-authored, project-local Remotion composition that BYPASSES the -cut-schema and the stock scene-type registry entirely — the "hand-stitched -every time" path for hero/bespoke pieces. See `_render_via_atelier`. +means the composition is hand-authored rather than assembled from stock scene +components. Runtime still wins first: HyperFrames atelier routes through +`hyperframes_compose`, FFmpeg stays FFmpeg-only, and only Remotion atelier uses +`_render_via_atelier` for a project-local Remotion entry that bypasses the +cut-schema and stock scene-type registry. Silent runtime swaps are forbidden by governance. If the chosen runtime is unavailable or fails, this tool surfaces a structured blocker and waits for @@ -187,6 +189,15 @@ class VideoCompose(BaseTool): "codec": {"type": "string", "default": "libx264"}, "crf": {"type": "integer", "default": 23}, "preset": {"type": "string", "default": "medium"}, + "remotion_timeout_ms": { + "type": "integer", + "description": ( + "Remotion render timeout in milliseconds, passed through as " + "`--timeout` (governs headless-browser setup and delayRender). " + "Raise this when the browser is slow to start (e.g. restricted " + "networks). The subprocess timeout is widened to match." + ), + }, }, } @@ -1293,6 +1304,36 @@ class VideoCompose(BaseTool): if not edit_decisions: return ToolResult(success=False, error="edit_decisions required for render") + # --- Runtime routing: honor render_runtime locked at proposal --- + # Silent swaps are forbidden by governance. Resolve this before any + # composition-mode branching so `composition_mode="atelier"` cannot + # accidentally force the Remotion atelier path when HyperFrames or + # FFmpeg was approved. + render_runtime = (edit_decisions.get("render_runtime") or "").strip().lower() + + if not render_runtime: + return ToolResult( + success=False, + error=( + "render_runtime is not set in edit_decisions. Per governance, " + "it MUST be locked at proposal stage (proposal_packet." + "production_plan.render_runtime) and carried forward through " + "edit_decisions.render_runtime. Valid values: 'remotion', " + "'hyperframes', 'ffmpeg'. Re-run the proposal stage with an " + "explicit runtime choice — do NOT default this field." + ), + ) + + if render_runtime not in {"remotion", "hyperframes", "ffmpeg"}: + return ToolResult( + success=False, + error=( + f"Unknown render_runtime {render_runtime!r}. " + f"Valid values: remotion, hyperframes, ffmpeg. " + f"render_runtime must be set at proposal stage." + ), + ) + # --- Atelier (bespoke) mode ------------------------------------- # Hand-authored, project-local Remotion composition. Deliberately # bypasses the cut-schema, the stock scene-type registry, and the @@ -1301,8 +1342,11 @@ class VideoCompose(BaseTool): # under remotion-composer/projects// and points this renderer at # it. No reusable creative components; a new visual language per video. # Triggered by composition_mode="atelier" (or renderer_family="bespoke"). - if (edit_decisions.get("composition_mode") == "atelier" - or edit_decisions.get("renderer_family") == "bespoke"): + remotion_atelier_requested = ( + edit_decisions.get("composition_mode") == "atelier" + or edit_decisions.get("renderer_family") == "bespoke" + ) + if render_runtime == "remotion" and remotion_atelier_requested: return self._render_via_atelier(inputs, edit_decisions) if not asset_manifest: @@ -1336,26 +1380,6 @@ class VideoCompose(BaseTool): # Also accept profile as "output_profile" (skill convention) or "profile" profile = inputs.get("profile") or inputs.get("output_profile") - # --- Runtime routing: honor render_runtime locked at proposal --- - # Silent swaps are forbidden by governance. If the chosen runtime - # is unavailable, surface a structured blocker rather than quietly - # picking a different engine. Missing render_runtime is itself a - # governance violation — edit_decisions.schema.json requires it. - render_runtime = (edit_decisions.get("render_runtime") or "").strip().lower() - - if not render_runtime: - return ToolResult( - success=False, - error=( - "render_runtime is not set in edit_decisions. Per governance, " - "it MUST be locked at proposal stage (proposal_packet." - "production_plan.render_runtime) and carried forward through " - "edit_decisions.render_runtime. Valid values: 'remotion', " - "'hyperframes', 'ffmpeg'. Re-run the proposal stage with an " - "explicit runtime choice — do NOT default this field." - ), - ) - if render_runtime == "hyperframes": return self._render_via_hyperframes( inputs=inputs, @@ -1374,16 +1398,6 @@ class VideoCompose(BaseTool): output_path=output_path, profile=profile, ) - if render_runtime != "remotion": - return ToolResult( - success=False, - error=( - f"Unknown render_runtime {render_runtime!r}. " - f"Valid values: remotion, hyperframes, ffmpeg. " - f"render_runtime must be set at proposal stage." - ), - ) - # --- Explicit Remotion path (render_runtime == 'remotion') --- if self._needs_remotion(resolved_cuts): remotion_inputs: dict[str, Any] = { @@ -1392,6 +1406,11 @@ class VideoCompose(BaseTool): } if profile: remotion_inputs["profile"] = profile + # Forward the creator-facing render timeout through the high-level + # render path (execute(operation="render") -> _render), otherwise it + # 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"] render_result = self._remotion_render(remotion_inputs) # Governance: NEVER silently fall back to FFmpeg when Remotion fails. @@ -1738,12 +1757,45 @@ class VideoCompose(BaseTool): except (ImportError, ValueError): pass + # Optional creator-facing render timeout. Remotion's `--timeout` (ms) + # governs headless-browser setup and delayRender(); on slow machines or + # restricted networks the default 30s browser setup times out with an + # 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 + if remotion_timeout_ms: + try: + ms = int(remotion_timeout_ms) + cmd.append(f"--timeout={ms}") + subprocess_timeout = max(subprocess_timeout, ms // 1000 + 60) + except (TypeError, ValueError): + pass + try: # Invoke from inside the composer dir so npx can resolve the # local remotion binary via node_modules/.bin. Without this, # Windows npx cannot locate the CLI and returns "could not # determine executable to run". - self.run_command(cmd, timeout=600, cwd=composer_dir) + self.run_command(cmd, timeout=subprocess_timeout, cwd=composer_dir) + except subprocess.CalledProcessError as e: + # run_command uses check=True + capture_output, so the useful + # Remotion diagnostics live in stderr/stdout — surface the tail + # instead of the bare "returned non-zero exit status 1". + detail = (e.stderr or e.stdout or "").strip() + tail = "\n".join(detail.splitlines()[-25:]) if detail else "(no output captured)" + return ToolResult( + success=False, + error=f"Remotion render failed (exit {e.returncode}):\n{tail}", + ) + except subprocess.TimeoutExpired as e: + return ToolResult( + success=False, + error=( + f"Remotion render timed out after {e.timeout}s. If the headless " + "browser is slow to start, raise remotion_timeout_ms (ms)." + ), + ) except Exception as e: return ToolResult(success=False, error=f"Remotion render failed: {e}") finally: