backlot: fix dogfood findings + global type scale

Fixes surfaced by the "How Salt Changed History" run:

- Decisions rail showed a stale choice. Contract now requires a superseding
  decision_log entry when a logged choice changes mid-run; the board collapses
  by category+subject and renders the latest as current, tagged "revised"
  (AGENT_GUIDE Decision Communication Contract; board.js renderDecisions).
- Assets gate jumped by a draft render. The review surface is the filmstrip:
  render per-scene stills to snapshots/<scene_id>.png (scripts/atelier_snapshots.py)
  and STOP; the full/draft render is the compose stage, post-approval
  (checkpoint-protocol, bespoke-composition).
- Broken thumbnails / missing scenes. A .tsx "animation" asset is no longer a
  renderable visual — resolve snapshots/<scene_id>.* or a shot-spec placeholder;
  add <img onerror> fallback and a BESPOKE placeholder (state.py, board.js).
- Clicking a card video did nothing. .thumb video had no sizing, so the frame
  and click box drifted; add object-fit:cover. Click-to-play on the render-hero
  player; narration is clickable -> modal with the full text (board.css/js).
- Type too small everywhere. Add one --fs-scale (1.16) knob; every font-size is
  calc(px * var(--fs-scale)) so text scales proportionally and reversibly,
  verified for zero overflow at desktop + tablet widths.
This commit is contained in:
calesthio
2026-07-03 06:55:43 -07:00
parent 80657bb820
commit 8fc3e9fbd1
7 changed files with 384 additions and 95 deletions

View File

@@ -114,6 +114,12 @@ The agent must ask the user before changing any major production choice, includi
Minor prompt refinements inside an already approved provider/model path do not require separate approval unless they materially change the creative direction.
### Re-log Changed Decisions (Binding)
The `decision_log` is the board's Decisions rail and the run's audit trail. It is **append-only history, not a scratchpad.** When a choice you already logged changes mid-run — the user swaps the voice, you switch provider/model/runtime/music, or a fallback overrides an earlier pick — you MUST **append a new `decision_log` entry** for the new choice (same `category`, e.g. `voice_selection`), 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 renders the **latest** entry per `category` as current — so the fix is to write the new entry, never to silently mutate the old one. 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.

View File

@@ -311,12 +311,20 @@ def _asset_entry(project_dir: Path, asset: dict) -> dict:
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"),
@@ -328,6 +336,36 @@ def _asset_entry(project_dir: Path, asset: dict) -> dict:
}
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")
@@ -396,8 +434,12 @@ def _build_storyboard(
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")]
# Takes: multiple visual assets for the same slot, ordered as listed.
active_visual = visuals[-1] if visuals else None
# 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")]
# Active visual: newest renderable take, else a per-scene review
# snapshot, else nothing (card falls to the shot-spec placeholder).
active_visual = renderable[-1] if renderable else _find_scene_snapshot(project_dir, sid)
cards.append({
"id": sid,
"type": scene.get("type"),
@@ -418,7 +460,7 @@ def _build_storyboard(
"section_label": (section or {}).get("label"),
"required_assets": scene.get("required_assets") or [],
"visual": active_visual,
"takes": visuals,
"takes": renderable,
"audio": audio,
"generating": generating.get(sid) is not None,
"generating_tool": (generating.get(sid) or {}).get("tool"),

View File

@@ -29,6 +29,10 @@
--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; }
@@ -43,7 +47,7 @@ body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
font-size: calc(14px * var(--fs-scale));
line-height: 1.5;
min-height: 100vh;
/* faint vignette so media pops */
@@ -101,15 +105,15 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
border: 1px solid var(--border);
}
.slate h1 {
font-family: var(--mono); font-size: 17px; font-weight: 600;
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: 11px; letter-spacing: 0.22em;
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: 10.5px; letter-spacing: 0.06em;
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;
@@ -119,7 +123,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.live {
display: inline-flex; align-items: center; gap: 7px;
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
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; }
@@ -131,13 +135,13 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
}
.cost { text-align: right; }
.cost .nums { font-family: var(--mono); font-size: 13px; }
.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: 10px; color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; }
.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; }
@@ -145,7 +149,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.stage .node {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; z-index: 2; position: relative;
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);
}
@@ -155,10 +159,10 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
}
.stage:first-child .line { display: none; }
.stage .name {
margin-top: 10px; font-family: var(--mono); font-size: 11px;
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: 10.5px; color: var(--text-3); margin-top: 3px; text-align: center; max-width: 150px; }
.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; }
@@ -200,8 +204,8 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
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: 11px; font-weight: 600; letter-spacing: 0.18em; color: var(--text-2); text-transform: uppercase; }
.panel-head .meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin-left: auto; }
.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 ---------- */
@@ -222,31 +226,31 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
background: rgba(0,0,0,.07);
}
.script-card .sp-title {
text-align: center; font-weight: 700; font-size: 16px;
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: 11.5px; color: var(--cream-ink-2); margin-bottom: 26px; }
.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: 12.5px; text-transform: uppercase;
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: 11px; }
.script-card .sp-action { font-size: 13px; line-height: 1.62; }
.script-card .sp-paren { font-size: 11.5px; font-style: italic; color: var(--cream-ink-2); margin: 4px 0 0 42px; }
.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: 9.5px; font-style: normal;
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: 12px; font-weight: 700; margin-top: 20px; text-transform: uppercase; }
.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: 10px; color: var(--cream-ink-2); letter-spacing: .06em;
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: 10px; font-weight: 600; letter-spacing: .14em;
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;
}
@@ -255,19 +259,20 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.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: 9.5px; color: var(--text-3); letter-spacing: .1em; text-transform: uppercase; }
.decision .d-pick { font-size: 12.5px; font-weight: 600; margin-top: 3px; }
.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: 11.5px; color: var(--text-2); margin-top: 3px; line-height: 1.45; }
.decision .d-alt { font-size: 10.5px; color: var(--text-3); margin-top: 4px; }
.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: 11px; }
.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: 10px; flex: 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: 10.5px; }
.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); }
@@ -286,7 +291,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.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: 10px; letter-spacing: .05em;
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; }
@@ -300,15 +305,26 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(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: 9px; letter-spacing: .06em;
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: 26px; text-shadow: 0 2px 12px rgba(0,0,0,.7);
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; }
@@ -326,9 +342,9 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.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: 10px; color: var(--amber); letter-spacing: .08em;
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: 9.5px; letter-spacing: .03em; line-height: 1.5; }
.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; }
@@ -336,49 +352,61 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
position: absolute; inset: 0; padding: 10px 12px;
display: flex; flex-direction: column; justify-content: center; gap: 4px;
}
.thumb.spec .spec-desc { font-size: 10.5px; 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: 9px; color: #4a4a54; letter-spacing: .04em; }
.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: 15px; }
.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: 11px; color: #d8d8de; padding: 0 10px;
letter-spacing: .2em; font-size: calc(11px * var(--fs-scale)); color: #d8d8de; padding: 0 10px;
}
.narr {
padding: 8px 3px 0; font-size: 11px; color: var(--text-2); line-height: 1.45;
font-style: italic; max-height: 52px; overflow: hidden;
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: 9px; color: var(--text-3); margin-left: 6px; align-self: center; }
.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: 9px; color: var(--text-3); letter-spacing: .05em; }
.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: 12px; letter-spacing: .12em; text-transform: uppercase; margin-bottom: 6px; color: #4a4a54; }
.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: 10.5px; }
.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); }
@@ -393,7 +421,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.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: 12px; cursor: pointer; letter-spacing: .1em;
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;
}
@@ -405,17 +433,17 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.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: 9px; letter-spacing: .12em;
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: 12px; color: #3f3f4a; }
.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: 12.5px; font-weight: 600; letter-spacing: .05em; }
.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: 9.5px; padding: 2px 7px; }
.lib-body .lb-meta .when { font-size: 10.5px; color: var(--text-3); margin-left: auto; }
.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; }
@@ -426,16 +454,16 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.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: 12.5px; color: var(--text-2); margin: 18px 0 4px;
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: 11px; font-weight: 600; letter-spacing: .18em;
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: 10.5px; color: var(--text-3); font-weight: 400; letter-spacing: .05em; margin-left: auto; }
a.backlink { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); text-decoration: none; letter-spacing: .08em; }
.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); }
/* ============================================================
@@ -457,29 +485,29 @@ a.backlink:hover { color: var(--text-2); }
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: 12px; 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: 11px; }
.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: 11px; line-height: 1.55; color: var(--text-2);
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: 9.5px; letter-spacing: .08em;
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: 9.5px; letter-spacing: .06em;
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: 10.5px; color: var(--text-3); padding: 8px 2px; display: flex; gap: 14px; flex-wrap: wrap; }
.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); }
@@ -499,19 +527,19 @@ a.backlink:hover { color: var(--text-2); }
}
.replay-bar input[type=range] { flex: 1; accent-color: var(--amber); }
.replay-bar .rp-btn {
font-family: var(--mono); font-size: 11px; letter-spacing: .08em; cursor: pointer;
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: 10.5px; color: var(--text-3); min-width: 130px; text-align: right; }
.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: 12px; color: var(--text-3); padding: 10px 2px; }
.hint { font-size: calc(12px * var(--fs-scale)); color: var(--text-3); padding: 10px 2px; }
a { color: inherit; }
@@ -550,8 +578,8 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
scrollbar-width: thin;
}
.stage { flex: 0 0 82px; }
.stage .name { font-size: 10px; max-width: 76px; overflow-wrap: anywhere; text-align: center; }
.stage .sub { max-width: 76px; font-size: 9.5px; }
.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; }
@@ -578,10 +606,10 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
.wrap { padding: 0 12px 52px; }
.slate { padding-top: 14px; }
.clapper { width: 30px; height: 23px; }
.slate .wordmark { font-size: 10px; }
.slate h1 { font-size: 15px; letter-spacing: .06em; }
.chip { font-size: 9.5px; padding: 3px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.live { font-size: 10px; letter-spacing: .1em; }
.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%; }
@@ -593,8 +621,8 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
padding: 24px 20px 28px;
border-radius: 5px;
}
.script-approved { top: 14px; right: 16px; font-size: 9px; padding: 2px 6px; }
.script-card .sp-title { font-size: 14px; padding-right: 58px; }
.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; }
@@ -602,6 +630,6 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
.panel-head { flex-wrap: wrap; }
.panel-head .meta { margin-left: 0; }
.drawer .drawer-head { flex-wrap: wrap; }
.drawer pre { font-size: 10.5px; }
.drawer pre { font-size: calc(10.5px * var(--fs-scale)); }
.scene-card { max-width: calc(100vw - 42px); }
}

View File

@@ -155,7 +155,7 @@ function renderDrawer(s) {
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:11.5px;color:var(--text-2);margin-left:8px" }, st.review.summary) : null,
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,
));
}
@@ -166,7 +166,7 @@ function renderDrawer(s) {
if (!artifact) continue;
shown = true;
body.append(
el("div", { class: "d-cat", style: "font-family:var(--mono);font-size:9.5px;color:var(--text-3);letter-spacing:.1em;text-transform:uppercase;margin:6px 0 4px" }, name),
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)),
);
}
@@ -180,7 +180,7 @@ function renderDrawer(s) {
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:10.5px;color:var(--text-3)" }, st.timestamp) : 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,
@@ -248,6 +248,23 @@ function openScriptModal() {
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(); });
@@ -261,12 +278,28 @@ function renderDecisions(s) {
const decisions = (log && log.decisions) || [];
if (!decisions.length) return null;
const body = el("div", { class: "panel-body" });
for (const d of decisions.slice(-8).reverse()) {
// 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}` : ""}`),
el("div", { class: "d-pick" }, `${d.subject || ""} `, el("span", { class: "arrow" }, "→"), ` ${d.selected || ""}`),
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,
@@ -373,10 +406,29 @@ function sceneCard(s, card) {
if (vid.paused) vid.play(); else vid.pause();
};
} else {
thumb = el("div", { class: "thumb approved" },
el("img", { src: thumbURL(s.project_id, v.path, 640), loading: "lazy", alt: "" }),
badge ? el("span", { class: "badge" }, badge) : null);
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" },
@@ -406,7 +458,7 @@ function sceneCard(s, card) {
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:8.5px;letter-spacing:.04em;color:#62626c;border:1px solid #212129;border-radius:3px;padding:1px 5px" }, String(t).replaceAll("_", " ")))));
.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
@@ -426,9 +478,14 @@ function sceneCard(s, card) {
wrap.append(takes);
}
// narration + audio
// narration + audio — clickable to read in full (F: narration text cut off)
if (card.narration) {
wrap.append(el("div", { class: "narr" }, 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)));
}
@@ -472,6 +529,9 @@ function renderRenders(s) {
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;
@@ -510,7 +570,7 @@ function renderFoundMedia(s) {
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:15px" }, "◌"),
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. ",
@@ -521,7 +581,7 @@ 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:16px" }, "◈"),
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."));

View File

@@ -0,0 +1,121 @@
"""Render one review still per scene for an atelier (bespoke) composition.
The Backlot storyboard can't thumbnail a `.tsx` scene, so a bespoke run
populates the assets-gate filmstrip by writing `projects/<slug>/snapshots/
<scene_id>.png` — one Remotion `still` per scene at a representative frame.
Run this AT THE ASSETS GATE (before any draft/compose render):
python scripts/atelier_snapshots.py <slug>
It reads scene timings from `artifacts/scene_plan.json` and the bespoke render
config from `artifacts/edit_decisions.json` (falling back to conventional
paths: index.tsx / artifacts/props.json / public/). The composition id comes
from edit_decisions.bespoke.composition_id or --composition-id.
See skills/meta/bespoke-composition.md and skills/meta/checkpoint-protocol.md.
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
# On Windows npx is npx.cmd — resolve it so subprocess finds it without a shell.
NPX = shutil.which("npx") or "npx"
REPO_ROOT = Path(__file__).resolve().parent.parent
COMPOSER_DIR = REPO_ROOT / "remotion-composer"
def _load(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("slug", help="project slug under projects/")
ap.add_argument("--composition-id", help="Remotion composition id (else from edit_decisions)")
ap.add_argument("--entry", help="entry .tsx (default projects/<slug>/index.tsx)")
ap.add_argument("--props", help="props JSON (default artifacts/props.json)")
ap.add_argument("--public-dir", help="public dir (default projects/<slug>/public)")
ap.add_argument("--fps", type=int, default=None, help="frames per second (default from props or 30)")
ap.add_argument("--only", nargs="*", help="only these scene ids")
args = ap.parse_args(argv)
proj = REPO_ROOT / "projects" / args.slug
if not proj.is_dir():
print(f"error: no project at {proj}", file=sys.stderr)
return 2
scene_plan = _load(proj / "artifacts" / "scene_plan.json")
scenes = (scene_plan.get("scenes") or []) if isinstance(scene_plan, dict) else []
if not scenes:
print("error: no scenes in artifacts/scene_plan.json", file=sys.stderr)
return 2
edit = _load(proj / "artifacts" / "edit_decisions.json")
bespoke = (edit.get("bespoke") or {}) if isinstance(edit, dict) else {}
props_path = Path(args.props or bespoke.get("props_path") or (proj / "artifacts" / "props.json"))
entry = Path(args.entry or bespoke.get("entry") or (proj / "index.tsx"))
if not entry.is_absolute():
entry = (REPO_ROOT / entry).resolve()
public_dir = Path(args.public_dir or bespoke.get("public_dir") or (proj / "public"))
comp_id = args.composition_id or bespoke.get("composition_id")
if not comp_id:
print("error: composition id unknown (pass --composition-id or set edit_decisions.bespoke)", file=sys.stderr)
return 2
fps = args.fps
if fps is None:
props = _load(props_path)
fps = int(props.get("fps") or 30)
# Stage the project into remotion-composer so webpack resolves node_modules.
sys.path.insert(0, str(REPO_ROOT))
from tools.video.video_compose import VideoCompose # noqa: E402
staged_entry = VideoCompose()._stage_atelier_project(entry, COMPOSER_DIR)
snap_dir = proj / "snapshots"
snap_dir.mkdir(exist_ok=True)
ok, fail = 0, 0
for sc in scenes:
sid = str(sc.get("id") or "").strip()
if not sid:
continue
if args.only and sid not in args.only:
continue
start = sc.get("start_seconds")
end = sc.get("end_seconds")
mid = ((start + end) / 2) if (start is not None and end is not None) else (start or 0)
frame = max(0, round(mid * fps))
out = snap_dir / f"{sid}.png"
cmd = [
NPX, "remotion", "still", str(staged_entry), str(comp_id), str(out.resolve()),
f"--frame={frame}",
f"--props={props_path.resolve()}",
f"--public-dir={public_dir.resolve()}",
]
try:
subprocess.run(cmd, cwd=COMPOSER_DIR, check=True, capture_output=True, text=True, timeout=600)
ok += 1
print(f" {sid}: frame {frame} -> {out.relative_to(REPO_ROOT)}")
except subprocess.CalledProcessError as e:
fail += 1
print(f" {sid}: FAILED — {(e.stderr or e.stdout or '')[-300:]}", file=sys.stderr)
except Exception as e: # noqa: BLE001
fail += 1
print(f" {sid}: FAILED — {e}", file=sys.stderr)
print(f"snapshots: {ok} ok, {fail} failed -> {snap_dir.relative_to(REPO_ROOT)}")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -224,8 +224,27 @@ registry (`src/components`, `src/Explainer`, etc.), and warns if `art_direction`
so the user opts in knowingly. Quality varies more without a stock baseline — mitigate with strong
principle skills (above) and the distinctness review, not by reintroducing reuse.
- **Checkpoint cadence.** Follow `skills/meta/checkpoint-protocol.md`: present script + scene plan
for approval BEFORE generating assets, then a footage/asset checkpoint, then a first-render
checkpoint. Do not batch-generate ahead of sign-off.
for approval BEFORE generating assets, then the **assets gate**, then a first-render checkpoint.
Do not batch-generate ahead of sign-off, and **do not render a draft to earn the assets review**
the assets gate is held *before* compose (see below).
- **Populate the filmstrip with per-scene stills at the assets gate.** A bespoke scene's "asset" is
a `.tsx` composition — not thumbnailable — so the board can't show it until a still exists. Once
the composition compiles, render one still per scene at a representative frame into
`projects/<slug>/snapshots/<scene_id>.png`, so the assets-gate filmstrip shows real frames instead
of "◆ BESPOKE" placeholders. Use Remotion's still renderer (fast — one frame each), driven off the
scene_plan timings:
```bash
# one still per scene at mid-scene frame (fps * mid_seconds), into snapshots/<scene_id>.png
npx remotion still projects/<slug>/index.tsx <CompositionId> \
projects/<slug>/snapshots/<scene_id>.png \
--frame=<mid_frame> --props=<abs artifacts/props.json> --public-dir=<abs public/>
```
A helper that reads the scene_plan and renders all stills is at
`scripts/atelier_snapshots.py` (`python scripts/atelier_snapshots.py <slug>`). Then STOP at the
assets gate. The full/draft render is the **compose** stage, after approval.
## Worked precedents (for the *workflow*, not the look)

View File

@@ -148,10 +148,23 @@ When `human_approval_default: true`:
`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.** `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.
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