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/.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 4d374d28..0ffcda2d 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. @@ -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 @@ -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: diff --git a/README.md b/README.md index c53432ca..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 @@ -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/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/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/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/pipeline_defs/animated-explainer.yaml b/pipeline_defs/animated-explainer.yaml index 0e067ab1..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 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 b0ac36ad..e7f2b802 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,8 @@ Pillow>=10.0 numpy>=1.24 requests>=2.31 google-auth>=2.0 # service-account auth for Google TTS + Imagen (Vertex AI) + +# Backlot — the living storyboard (local board server) +fastapi>=0.110 +uvicorn>=0.29 +watchfiles>=0.21 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/meta/bespoke-composition.md b/skills/meta/bespoke-composition.md index c2cadf9b..dc2e13b9 100644 --- a/skills/meta/bespoke-composition.md +++ b/skills/meta/bespoke-composition.md @@ -224,8 +224,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/pipelines/animation/asset-director.md b/skills/pipelines/animation/asset-director.md index d8bbf55a..59ab5c18 100644 --- a/skills/pipelines/animation/asset-director.md +++ b/skills/pipelines/animation/asset-director.md @@ -164,3 +164,12 @@ This is especially important for: - **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 41d21579..e2cd27dd 100644 --- a/skills/pipelines/animation/proposal-director.md +++ b/skills/pipelines/animation/proposal-director.md @@ -466,3 +466,12 @@ This is especially important for: - **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 1600e3b2..fb7a2d92 100644 --- a/skills/pipelines/avatar-spokesperson/asset-director.md +++ b/skills/pipelines/avatar-spokesperson/asset-director.md @@ -129,3 +129,12 @@ This is especially important for: - **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 036eae8e..05a0105d 100644 --- a/skills/pipelines/cinematic/asset-director.md +++ b/skills/pipelines/cinematic/asset-director.md @@ -162,3 +162,12 @@ This is especially important for: - **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 d40a2a70..1bbdcbd4 100644 --- a/skills/pipelines/cinematic/proposal-director.md +++ b/skills/pipelines/cinematic/proposal-director.md @@ -290,3 +290,12 @@ This is especially important for: - **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 6655f211..37bd85c2 100644 --- a/skills/pipelines/clip-factory/asset-director.md +++ b/skills/pipelines/clip-factory/asset-director.md @@ -106,3 +106,12 @@ This is especially important for: - **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 2215e8a5..6bac4737 100644 --- a/skills/pipelines/explainer/asset-director.md +++ b/skills/pipelines/explainer/asset-director.md @@ -278,3 +278,12 @@ This is especially important for: - **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 086970c8..e4384e37 100644 --- a/skills/pipelines/explainer/proposal-director.md +++ b/skills/pipelines/explainer/proposal-director.md @@ -540,3 +540,12 @@ This is especially important for: - **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 16a12081..24f9b0d7 100644 --- a/skills/pipelines/explainer/publish-director.md +++ b/skills/pipelines/explainer/publish-director.md @@ -162,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 2141ce5b..9642d666 100644 --- a/skills/pipelines/explainer/scene-director.md +++ b/skills/pipelines/explainer/scene-director.md @@ -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 8213e389..8fe60dec 100644 --- a/skills/pipelines/hybrid/asset-director.md +++ b/skills/pipelines/hybrid/asset-director.md @@ -98,3 +98,12 @@ This is especially important for: - **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 18d7e562..2d6809ff 100644 --- a/skills/pipelines/localization-dub/asset-director.md +++ b/skills/pipelines/localization-dub/asset-director.md @@ -90,3 +90,12 @@ This is especially important for: - **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 d78df75b..07a951a3 100644 --- a/skills/pipelines/podcast-repurpose/asset-director.md +++ b/skills/pipelines/podcast-repurpose/asset-director.md @@ -103,3 +103,12 @@ This is especially important for: - **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 770683c9..36f09cf6 100644 --- a/skills/pipelines/screen-demo/asset-director.md +++ b/skills/pipelines/screen-demo/asset-director.md @@ -164,3 +164,12 @@ This is especially important for: - **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 80c58bd8..d15ad931 100644 --- a/skills/pipelines/talking-head/asset-director.md +++ b/skills/pipelines/talking-head/asset-director.md @@ -206,3 +206,12 @@ This is especially important for: - **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/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_phase3_contracts.py b/tests/contracts/test_phase3_contracts.py index 17b93adb..1bd8aaa7 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): 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_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_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/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/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 83e6f428..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 @@ -1302,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 @@ -1310,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: @@ -1345,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, @@ -1383,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] = {