mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-18 22:38:30 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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."));
|
||||
|
||||
121
scripts/atelier_snapshots.py
Normal file
121
scripts/atelier_snapshots.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user