From 8fc3e9fbd1d470b5fb4642b0309b36c166bb3b6b Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 3 Jul 2026 06:55:43 -0700 Subject: [PATCH] backlot: fix dogfood findings + global type scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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/.* or a shot-spec placeholder; add 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. --- AGENT_GUIDE.md | 6 + backlot/state.py | 48 +++++++- backlot/ui/board.css | 172 +++++++++++++++++------------ backlot/ui/board.js | 88 ++++++++++++--- scripts/atelier_snapshots.py | 121 ++++++++++++++++++++ skills/meta/bespoke-composition.md | 23 +++- skills/meta/checkpoint-protocol.md | 21 +++- 7 files changed, 384 insertions(+), 95 deletions(-) create mode 100644 scripts/atelier_snapshots.py diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 488a03a1..c664c0d3 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -114,6 +114,12 @@ The agent must ask the user before changing any major production choice, includi Minor prompt refinements inside an already approved provider/model path do not require separate approval unless they materially change the creative direction. +### Re-log Changed Decisions (Binding) + +The `decision_log` is the board's Decisions rail and the run's audit trail. It is **append-only history, not a scratchpad.** When a choice you already logged changes mid-run — the user swaps the voice, you switch provider/model/runtime/music, or a fallback overrides an earlier pick — you MUST **append a new `decision_log` entry** for the new choice (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. diff --git a/backlot/state.py b/backlot/state.py index ee891878..c28d60b1 100644 --- a/backlot/state.py +++ b/backlot/state.py @@ -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 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/.png`) is what the filmstrip + shows. Accept exact `.` and `_*.` 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"), diff --git a/backlot/ui/board.css b/backlot/ui/board.css index 45655970..e4de79ff 100644 --- a/backlot/ui/board.css +++ b/backlot/ui/board.css @@ -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( * 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