Merge pull request #273 from calesthio/feat/backlot-living-storyboard
Release Backlot: the living storyboard for OpenMontage
This commit is contained in:
commit
f4b8b90a24
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -94,3 +94,6 @@ remotion-composer/public/demo-props/caption-burn-*
|
|||
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Backlot local cache (thumbnails)
|
||||
.backlot/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -111,6 +111,36 @@ Works with **Claude Code, Cursor, Copilot, Windsurf, Codex** — any AI coding a
|
|||
|
||||
---
|
||||
|
||||
## Watch It Happen — The Backlot Living Storyboard
|
||||
|
||||
Chat tells you what the agent *said*. **Backlot shows you what the production is actually doing** — a local board that fills itself in as the pipeline runs. Stages light up, the script lands as a screenplay page, scene cards shimmer while assets generate, and every provider decision and dollar spent is on the wall.
|
||||
|
||||
When a production starts, the agent opens it for you automatically. No setup, no reporting — the board derives everything from the project files the pipeline already writes.
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/board-live.png" alt="Backlot live board — assets generating" width="920"></p>
|
||||
|
||||
**The storyboard is now a real approval gate.** Asset generation pauses on a scene-by-scene contact sheet — takes, prompts, per-asset cost, quality scores — so you approve the visuals *before* the render, not after it's too late:
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/storyboard.png" alt="Backlot storyboard — filmstrip with takes and renders" width="920"></p>
|
||||
|
||||
Creative gates hold until you answer. The board shows what's waiting and why; you reply in chat:
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/script-gate.png" alt="Backlot script gate — awaiting approval" width="920"></p>
|
||||
|
||||
Every production on your machine, live-first, in the library:
|
||||
|
||||
<p align="center"><img src="docs/images/backlot/library.png" alt="Backlot library" width="920"></p>
|
||||
|
||||
```bash
|
||||
python -m backlot open # the library — every project on disk
|
||||
python -m backlot open <project-id> # one production's live board
|
||||
python scripts/backlot_simulate_run.py # no production yet? watch a simulated one live
|
||||
```
|
||||
|
||||
And when a run is done, hit **▶ REPLAY RUN** — the whole production replays from its timestamps, scrubbable end to end. See [`backlot/README.md`](backlot/README.md) for how it works.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
|
@ -568,6 +598,7 @@ OpenMontage treats video production like real engineering — with quality gates
|
|||
|
||||
### Quality Gates
|
||||
|
||||
- **Human approval gates are enforced, not suggested** — proposal, script, scene plan, generated assets, and publish all pause for your sign-off. The checkpoint writer rejects a "completed" gated stage without recorded approval, and every superseded checkpoint is archived so the audit trail (including gate transitions) survives revisions. Review happens visually on the [Backlot board](#watch-it-happen--the-backlot-living-storyboard).
|
||||
- **Pre-compose validation** — blocks render if the delivery promise is violated (e.g. "motion-led" video with 80% still images), slideshow risk score is critical, or renderer family is missing. Catches broken plans before wasting GPU time.
|
||||
- **Post-render self-review** — after every render, the runtime runs ffprobe validation, extracts frames at 4 positions to check for black frames and broken overlays, analyzes audio levels for silence and clipping, verifies the delivery promise was honored, and checks subtitle presence. If the review fails, the video is not presented.
|
||||
- **Slideshow risk scoring** — 6-dimension analysis (repetition, decorative visuals, weak motion, shot intent, typography overreliance, unsupported cinematic claims) prevents "animated PowerPoint" outputs.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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: "✕",
|
||||
};
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
httpx2>=2.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue