Merge pull request #273 from calesthio/feat/backlot-living-storyboard

Release Backlot: the living storyboard for OpenMontage
This commit is contained in:
Calesthio 2026-07-03 07:29:04 -07:00 committed by GitHub
commit f4b8b90a24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
128 changed files with 6787 additions and 109 deletions

View File

@ -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/<id>/` 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.

17
.claude/launch.json Normal file
View File

@ -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
}
]
}

12
.codex/prompts/backlot.md Normal file
View File

@ -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 <project-id>
```
- 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/<id>/` on disk; never update the UI manually. Keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md`.

View File

@ -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 <project-id>
```
- 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/<id>/` on disk; never update the UI manually. Keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md`.

12
.github/prompts/backlot.prompt.md vendored Normal file
View File

@ -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 <project-id>
```
- 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/<id>/` on disk; never update the UI manually. Keep checkpoints and artifacts honest per `skills/meta/checkpoint-protocol.md`.

3
.gitignore vendored
View File

@ -94,3 +94,6 @@ remotion-composer/public/demo-props/caption-burn-*
venv/
.venv/
# Backlot local cache (thumbnails)
.backlot/

View File

@ -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/<project-name>/
**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('<project-id>', title='<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:

View File

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

42
backlot/README.md Normal file
View File

@ -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`.

16
backlot/__init__.py Normal file
View File

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

109
backlot/__main__.py Normal file
View File

@ -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())

341
backlot/server.py Normal file
View File

@ -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()

703
backlot/state.py Normal file
View File

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

635
backlot/ui/board.css Normal file
View File

@ -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); }
}

15
backlot/ui/board.html Normal file
View File

@ -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</title>
<link rel="stylesheet" href="/ui/board.css">
</head>
<body>
<div class="wrap" id="app"></div>
<div class="modal-bg" id="modal"></div>
<audio id="player"></audio>
<script type="module" src="/ui/board.js"></script>
</body>
</html>

830
backlot/ui/board.js Normal file
View File

@ -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));
}

26
backlot/ui/index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backlot — Library</title>
<link rel="stylesheet" href="/ui/board.css">
</head>
<body>
<div class="wrap">
<header class="slate">
<div class="clapper"></div>
<div>
<span class="wordmark">Backlot</span>
<h1>Library</h1>
</div>
<span class="chip" id="count"></span>
<div class="spacer"></div>
<span class="live idle" id="liveBadge"><span class="dot"></span><span id="liveText">IDLE</span></span>
</header>
<div class="lib-grid" id="grid"></div>
<p class="hint" id="empty" style="display:none">No projects yet — run a production and it will appear here.</p>
</div>
<script type="module" src="/ui/library.js"></script>
</body>
</html>

103
backlot/ui/lib.js Normal file
View File

@ -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: "✕",
};

64
backlot/ui/library.js Normal file
View File

@ -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));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

View File

@ -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/<project_id>/ — 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/<project_id>/ 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

118
lib/events.py Normal file
View File

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

17
lib/paths.py Normal file
View File

@ -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"))

View File

@ -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"]:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -2,3 +2,4 @@
-r requirements.txt
pytest>=8.0
pytest-asyncio>=0.23
httpx2>=2.0

View File

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

View File

@ -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/<slug>/snapshots/
<scene_id>.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 <slug>
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/<slug>/index.tsx)")
ap.add_argument("--props", help="props JSON (default artifacts/props.json)")
ap.add_argument("--public-dir", help="public dir (default projects/<slug>/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())

View File

@ -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()

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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/<slug>/snapshots/<scene_id>.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/<scene_id>.png
npx remotion still projects/<slug>/index.tsx <CompositionId> \
projects/<slug>/snapshots/<scene_id>.png \
--frame=<mid_frame> --props=<abs artifacts/props.json> --public-dir=<abs public/>
```
A helper that reads the scene_plan and renders all stills is at
`scripts/atelier_snapshots.py` (`python scripts/atelier_snapshots.py <slug>`). Then STOP at the
assets gate. The full/draft render is the **compose** stage, after approval.
## Worked precedents (for the *workflow*, not the look)

View File

@ -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/<id>/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/<project_id>/checkpoint_<stage>.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/<id>/snapshots/<scene_id>.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,3 +55,12 @@ projects/<project-name>/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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More