SCENEGOD/logs/laneC.md
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual
quality, discoverability):
- ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun
  intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and
  a warm key rendered neutral. Single biggest look win in the app.
- Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates
  that no longer double as the shadow catcher (dedicated ShadowMaterial).
- A Render button in the scene bar. finalRender had ZERO callers - the app's
  deliverable was console-only.
- Save actually saves the stage you built (gizmo moves, inspector edits), and
  Save/Load stop lying about what happened.
- 2x supersample + lanczos downscale; correct bt709 tagging and faststart.
- Frame guide, camera-from-view, dock counts, delete confirmations.

DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first):
- BLOCKER: save wrote the playhead pose over the authored rest transform of
  keyed entities, so every save produced a different file.
- renders were converted with the bt601 matrix while tagged bt709.
- deleting an unkeyed camera stranded its cut -> scene 422s forever.
- corner plates showed one photo at two exposures across the fold.
- exposure was advertised as keyable but nothing keyed it, so a second lighting
  preset permanently poisoned the first.
- the audio pre-flight could never fire (FastAPI does not route HEAD -> 405).
- the two render buttons were not mutually exclusive.
- frame guide letterboxed a narrow viewport that the render does not crop.

ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and
skipped by cutCameraAt, rather than trying to keep every undo history clean -
deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry
can resurrect a cut for a dead camera. Verified against the real validator.
Also replaced a VACUOUS test that drained a stack whose only entry was a seeded
no-op; timeline_test.mjs now replays both real histories and discriminates.

Verified: 4 JS suites + 23 server groups + render client green; blocker repro
now preserves the authored rest at every playhead position; save round-trips
200 with zero dangling cuts; zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:41:17 +10:00

52 KiB
Raw Blame History

Lane C log — append-only, newest at bottom

Format per session:

## <date> session N
DONE: …
DECISIONS: …
BLOCKED/REQUESTS: …
NEXT: …

2026-07-18 session 1

DONE: M1 code-complete. Commit bb85eef.

  • requirements.txt (fastapi, uvicorn only); .gitignore += .venv/.
  • scenegod/server.py single file: statics (/ no-store placeholder, /web/{path} short-cache, path-guarded), /assets/tree (5s cache, per-category grouping), /assets/file (traversal-guarded), /scenes CRUD with atomic write + slugify + validate_scene, render endpoints (begin/frame accept + save; end/status/out 501 until M3).
  • scripts/test_server.py: 5 groups green (tree grouping, file stream, 4 traversal attempts rejected, scene round-trip w/ slugify, 5 validation failures + 1 fade-overlap-allowed). OK: 5 test groups passed. DECISIONS:
  • System python3 is 3.14 (fastapi present but pydantic missing); made a .venv on python3.11 per CLAUDE.md stack rule. Tests + server use .venv/bin/python. .venv/ gitignored.
  • web/ is under scenegod/web/ per PLAN §2 (not repo root) — WEB points there; Lane A/B statics resolve via /web/{path}.
  • Asset grouping: per-category primary ext set (models for chars/anims/ props, images for backdrops, audio for audio). Non-primary image sharing a stem → thumb, not a format. path prefers glb else first model. Entries with no primary format dropped (thumb-only stems aren't assets).
  • Clip-overlap check: violation if next.start < a.end - a.fade, where a.end = start + (out-in)*loop. Fade-region overlap allowed. BLOCKED/REQUESTS: none. NEXT: M2 (when unlocked): scene-validation hardening + asset thumbnails endpoint. M3 render pipeline (ffmpeg encode + web/render.js) is designed in C-server.md §C5, endpoints already stubbed — implement at SYNC-2/M3.

2026-07-18 session 2

DONE: M2 thumbnails + M3 render pipeline (orchestrator green-lit both). Commit b627a0f.

  • /render/begin (uuid dir + meta.json, 503 if no ffmpeg), /frame/{n} (PNG->frames/{n:06d}.png, 400 on empty), /end (spawns bg ffmpeg thread: libx264 crf18 yuv420p, 400 if no frames), /status (queued|encoding| done|error+log tail; on-disk out.mp4 fallback after restart), /out.mp4.
  • Boot-time reap_renders() deletes render dirs >48h.
  • /assets/thumb?path= serves same-stem image sidecar (404 if none).
  • web/render.js: draftRecord (MediaRecorder webm), finalRender (timeline.step -> renderActiveCamera -> toBlob -> POST, <=4 in flight, resize+restore renderer), download() helper. node --check clean.
  • test_server.py: added render group (ffmpeg lavfi -> 30 PNGs -> begin -> post -> end -> poll -> ffprobe duration ~1s) + thumbnail asserts. OK: 6 test groups passed. DECISIONS:
  • Render state in-memory (single-process local tool); status() infers from disk after a restart. ponytail-commented.
  • Thumbnails pass-through, no resize — Pillow would be a new dep (stack rule says log a REQUEST first); not worth it until dock load is measurably slow.
  • finalRender total frames = round(duration*fps); relies on timeline.step being deterministic (orchestrator verified). BLOCKED/REQUESTS:
  • finalRender's per-frame path depends on Lane A stage.renderActiveCamera as a real render target (their M2). Server + ffmpeg path fully tested with synthetic frames; the browser client path is unexercised until A lands it. NEXT: M4 is locked. When unlocked: ffmpeg audio mux (scene audio[]), /mb/submit MODELBEAST proxy, scripts/graft_limb.py. Meanwhile available to integration-test finalRender once Lane A's renderActiveCamera lands.

2026-07-18 session 3

DONE: M4 complete (unlocked after SYNC 3). Commit be65355.

  • Audio mux: /render/end now accepts {audio:[{path,start,gain}]}, muxes via ffmpeg adelay+volume+amix -> aac -shortest; audio paths path-guarded (400 on missing). render.js finalRender passes opts.audio through.
  • scripts/graft_limb.py: headless Blender (blender -b -P ... -- --body B --limb L --bone NAME --out O). Align limb root head->target bone world head, join armatures, parent limb root under target (keep offset), join meshes (vgroup weights survive). Refusal: overlapping bones -> GraftRefused -> exit 2 "re-skin, don't graft". No finish/decimate. --selftest builds synthetic body/limb GLBs and verifies refusal + graft paths under Blender.
  • /mb/submit: MODELBEAST proxy, SCENEGOD_MB=1 gated (503 otherwise), token from ~/Documents/backnforth/.env at request time (never logged; errors scrub it), /api/assets multipart -> /api/jobs, mirrors meshgod/ops.py.
  • test_server.py: 8 groups green (added audio-stream assert, MB gating, MB two-step contract via in-process mock — no farm jobs). DECISIONS:
  • Blender 5 glTF importer injects a stray unparented 42-vert icosphere on every import; _import now keeps only rig-connected meshes (parented to armature / has vgroups / armature modifier) and deletes strays. Right filter for a graft tool regardless.
  • graft refusal check: provides = limb bones - {limb root}; refuse if provides ∩ body bones. Attach/root bone sharing the target name is expected.
  • MB test uses a stdlib http.server mock + a 2nd scenegod instance; asserts Bearer token forwarded, multipart upload, asset_id chained into job payload. BLOCKED/REQUESTS: none. BACKLOG (from orchestrator, not urgent): draft webm via MediaRecorder is untested in the embedded preview (rAF suspension). Needs one verification in a real Chrome window before calling draftRecord done — client-only, needs Lane A stage running live; deferred. NEXT: Lane C M1-M4 all shipped. Available for polish / integration support. Blender binary here is /Applications/Blender.app/Contents/MacOS/Blender (not on PATH as blender in this session, contrary to orchestrator note).

2026-07-18 session 3b

DONE: optional item from orchestrator — /rhubarb?path= endpoint. Commit fb59601.

  • GET /rhubarb?path= (path-guarded) -> rhubarb -f json <wav> -> returns {metadata, mouthCues:[{start,end,value}]} for Lane A's viseme lane. 503 with install hint when the binary is absent (ffmpeg-style gate).
  • test_server.py: +503-when-absent assert (9 groups green). DECISIONS: synchronous subprocess (VO clips are short); rhubarb NOT installed on ultra, so only the 503 path is exercised here — happy path (JSON parse) is untested until rhubarb is on PATH. Flagged same as MediaRecorder backlog. BLOCKED/REQUESTS: none. To fully verify: install rhubarb-lip-sync on ultra, then GET /rhubarb?path=audio/.wav. NEXT: nothing outstanding — Lane C M1-M4 + rhubarb shipped. Standing by.

2026-07-20 session 4

DONE: M5 /director + M6 render.js sliver.

  • scenegod/server.py: POST /director {text, scene:{entities,time}} -> OpenAI-compat chat (SCENEGOD_LLM_URL + /v1/chat/completions, model SCENEGOD_LLM_MODEL default qwen2.5:7b, temp 0.1, response_format json_object, stdlib urllib, 120s timeout). 503 + env hint when URL unset; 502 on LLM error/unreachable/unparseable output — raw model text is NEVER forwarded. _validate_ops: ops shot/light/mark checked against vocab + scene entity ids; violations dropped into rejected [{op, reason}]; response {ops, rejected}. Shot defaults angle="eye", focal=35 filled server-side.
  • Vocab (SHOTS/ANGLES/FOCALS/LIGHTS/MARKS) defined ONCE at top of the director section, mirroring PLAN §5 M5.
  • web/render.js finalRender: after timeline.step(f), before capture: if (stage.syncVideos) await stage.syncVideos(timeline.time) — guarded no-op until Lane A lands it (M6 determinism hook).
  • scripts/test_server.py: +director group (in-process http.server LLM mock, MB-mock pattern): 503 when env unset, 3 valid ops pass through verbatim, unknown op / unknown entity id / unknown preset land in rejected with reasons, prompt carries 10 vocab words + roster ids + playhead, model name
    • low temp asserted. OK: 9 test groups passed (rhubarb-503 group now self-skips — rhubarb is installed on ultra, so that group's absent-path doesn't run; happy path was orchestrator-verified live). DECISIONS:
  • web/grammar.js does NOT exist yet (no presets.js either) — vocab lives in server.py per spec fallback. AT SYNC: re-point the SHOTS/ANGLES/FOCALS/ LIGHTS/MARKS lists + DIRECTOR_SYS at Lane A's grammar.js (read at boot) so server prompt and client presets can't drift. Op shapes I defined (scenegod:direct): {op:"shot",subject,shot,angle,focal} / {op:"light",preset} / {op:"mark",subject,mark,target?} — Lane A's presets.js appliers must accept exactly these.
  • Model may return {"ops":[...]} or a bare array — both accepted (json_object mode forces an object wrapper; bare array tolerated for servers that ignore response_format).
  • Env read at request time (matches _mb_token pattern) so John can set SCENEGOD_LLM_URL without a restart-order footgun; test env explicitly pops it for the 503 assert.
  • focal coerced via int() before vocab check (7b models love "35" strings). BLOCKED/REQUESTS:
  • Lane A: need web/grammar.js (data only) + presets.js appliers matching the op shapes above; flag any shape disagreement at SYNC and I'll conform.
  • Live end-to-end against real qwen2.5:7b on m4pro untested this session (SCENEGOD_LLM_URL unset locally by design) — orchestrator: one smoke call at SYNC with env set is the acceptance step. NEXT: M6 FLOW VIDEO PLATES server side (unlocked on this acceptance): flow_intake.py poster-jpg intake is already in scripts/; verify video exts in /assets/tree (mp4 not in ASSET_EXTS yet — backdrops video category will need it) + deterministic render check once Lane A's stage.syncVideos lands.

2026-07-25 session 5

DONE: M7-C starter templates — server side complete, 10 test groups green.

  • templates/ at repo root (NOT gitignored — ships with the code, unlike assets/scenes/renders). Three files, all valid against the live validate_scene, every referenced path verified present in assets/:
    • templates/two-hander-golden-hour.json — lady + man facing each other (yaw +0.55 / -0.6; characters face +Z at yaw 0 per presets.js), Flow golden-hour street plate (mode:"video", fit:"frustum", groundTint), grammar.js golden_hour key+ambient verbatim (#ffb469 @2.6 / #ffd9b0 @0.55, bg #d98a52, fog 0.02), cam1 "wide" fov40 @5m + cam2 "closer" fov32 @3.4m, cut at t=4.5. 8s. Both carry carrying.fbx; lady's block is trimmed in:2,out:10 so the two performances don't move in lockstep.
    • templates/record-store.json — record-store interior plate, man + carrying.fbx, one camera fov38, warm key + ambient (fog 0.012), 6s.
    • templates/empty-stage.json — one camera, day key+ambient, 10s, no characters, no plate. The clean start.
  • scenegod/server.py:31 TEMPLATES root (SCENEGOD_TEMPLATES, default ./templates); :261-293 the read-only endpoints: GET /templates -> [{name,title,description,thumb?}], GET /templates/{name} -> scene JSON, both through safe_under + slugify (same guard discipline as /scenes). No POST/DELETE by design — load a template, then POST /scenes/{name}.
  • scripts/test_server.py:137 templates group: lists exactly the three, order-leads with the two-hander, round-trips each one, rejects ../../etc/hosts / encoded .. / absolute / unknown (all >=400), write verbs 404/405, and — the check that stops a rotten template shipping — POSTs every template through the real validate_scene and asserts every source.path / params.video|image / clips[].path / audio[].path / thumb exists under assets/, plus camera+light source.type == "none". OK: 10 test groups passed.
  • .gitignore: *.log (a 65KB scenegod-8020.log was sitting untracked at repo root, one git add away from the repo). DECISIONS:
  • Presentation metadata rides INSIDE the scene JSON under a template key ({title, description, thumb, order}) rather than a sidecar or a filename convention: validate_scene ignores unknown top-level keys and Timeline.load/toJSON doesn't carry it, so a saved scene comes out as plain scene JSON with no template residue. order is server-side sort only (not in the response) so the picker leads with the wow template, not alphabetically.
  • Verified before shipping, not assumed: lady.glb is a CC_Base rig (103 CC_Base strings, 0 mixamo) — safe only because Lane A's M7-A alias layer landed; _normalize scales both characters to 1.7m with feet at y=0, so the camera heights/fovs above frame them for real (wide vspan 3.65m, closer 1.97m). Both clips probed under headless Blender: carrying.fbx = 10.0s and hiphop.fbx = 4.467s, and both are in-place (hips travel 10cm / 2cm), so no template performer can wander out of frame.
  • Video plates follow the orchestrator shape (params.video, no image): _buildBackdrop reads p.image || en.source.path, and source.path is the same mp4, so the fallback carries it. If Lane A ever drops that fallback, add image alongside video.
  • Cameras/lights get source:{type:"none"} (sync2-sunset's "upload" was the bug). Light rot is not decorative — _buildLight aims a directional down the wrapper's -Z, so every key light's euler is lookAtEuler(pos,[0,1,0]) computed with presets.js's own algebra. BLOCKED/REQUESTS:
  • @orchestrator: the uvicorn on :8020 is NOT --reload — it still 404s /templates. Restart it before browser-checking.
  • @Lane B (M7-B): the picker contract is GET /templates -> [{name,title,description,thumb?}] (already ordered; thumb is asset-relative -> assets/file?path= or assets/thumb?path=), then GET /templates/{name} -> feed straight to timeline.load() / stage.applyState(), and save with the existing POST /scenes/{name}. NEXT: nothing outstanding in Lane C. Backlog if wanted: a fourth template once a prop/screen asset bank exists, and the still-unverified MediaRecorder draft path (carried from session 3).

2026-07-25 session 6

DONE: M8-C MULTI-SHOT SEQUENCING — the film-level object. 15 test groups green (was 10). A scene is one take; a sequence is an ordered list of trimmed scene windows joined by cuts/dissolves over a film-level audio bed.

  • Sequence doc + validatorscenegod/server.py:443 plan_sequence() validates AND resolves in ONE pass (save-time validation and render-time planning can never disagree). Roots at :32-33 (SCENEGOD_SEQUENCES -> ./sequences, SCENEGOD_FILMS -> ./films, both mkdir'd on boot like scenes/renders). Violations, all 422 with a message per offence: version!=1, empty/oversized shots (max 200), unknown scene, bad scene name, in/out not numbers, in<0, in>=out, out past the scene's duration, unknown transition, transitionDur out of 0..10s, dissolve with dur<=0, dissolve not shorter than BOTH shots it joins (ffmpeg xfade requirement), scenes with mixed fps ("one film, one fps"), audio path traversal / missing file / bad start|gain.
  • Endpoints :563-800: GET /sequences (name,title,mtime,shots,duration,ok), GET|POST|DELETE /sequences/{name} (slugify + safe_under + capped 1MB + atomic write), POST /sequences/{name}/render -> {filmId,fps,width,height,duration, warnings,shots[]}, POST /films/{id}/shot/{i} {renderId}, POST /films/{id}/end, GET /films/{id}/status -> {state,shot,of,log}, GET /films/{id}/out.mp4. States: collecting -> queued -> encoding -> done|error.
  • The film render is a client-driven job (:648 _concat_film): the browser is the only thing that can render a scene, so the server hands out the resolved shot plan, the client renders each shot through the UNCHANGED /render/* session endpoints and reports renderId-per-index, and /end folds the shot mp4s with ONE ffmpeg filter_complex: each input scale=WxH,setsar=1,fps,format=yuv420p, then a left fold — cut = concat=n=2:v=1:a=0, dissolve = xfade=transition=fade:duration=D:offset= (running accumulated length - D). Film-level audio[] -> adelay/volume/amix/apad, -shortest. Film dirs reaped >48h (:75 reap_dirs(root), now called for RENDERS and FILMS).
  • scenegod/web/film.js (new): renderSequence(stage, timeline, seqName, opts) drives the whole loop — POST render -> per shot GET scenes/{scene} -> timeline.applyScene(json) (Lane B's single apply path) -> captureFrames over frames [infps, outfps) -> end+poll the shot -> POST films/{id}/shot/{i} -> films/{id}/end -> poll -> resolve with films/{id}/out.mp4. Plus list/get/save/deleteSequence helpers so a UI lane never hand-rolls (or absolutises) the URLs. Relative URLs only.
  • web/render.js refactored, not duplicated: the frame loop is now captureFrames(stage,timeline,{renderId,from,to,onProgress}) + withRenderSize / beginRender / endRender / pollRender / postJSON / getJSON, all exported; finalRender(stage,timeline,opts) keeps its exact old signature and is now 6 lines built from them. film.js imports them — one frame-post path in the repo.
  • sequences/demo-two-shot.json: 3 shots, 11.5s — sync2-sunset 0..4.5 (cam1) dissolve 0.5s into sync2-sunset 4.5..8.0 (cam2, same scene, other side of its cut), cut to sync1-demo 0..4.0. Verified live: GET /sequences -> ok:true duration 11.5, POST /sequences/demo-two-shot/render -> the 3-shot plan, and a full fake-shot end-to-end (lavfi mp4s registered through the real endpoints) produced out.mp4 at exactly 11.500000s.
  • scripts/test_server.py: +5 groups (:225 CRUD round-trip incl. slugify + lossless read-back + DELETE 200/404; :248 13 validation failures each asserted for its own message + traversal/unknown guards on GET and DELETE; :281 film smoke — two lavfi mp4s registered as shots, end, poll, ffprobe duration 5.0s = 2+3, output 64x64, plus guards: end-before-registration 400, bad index 400, traversal renderId 400, unknown renderId 400, 32x32 shot -> 400 "one resolution per film", unknown filmId 404; :340 dissolve variant -> 4.5s = 2+3-0.5 with an audio bed muxed; :370 the shipped example can't rot — every shot's scene must exist and every out must be within that scene's duration). OK: 15 test groups passed. DECISIONS:
  • One fps per film, validated rather than resampling: timeline.step(frame) is the deterministic seek and it divides by the SCENE's fps, so a film whose fps differed from a scene's would silently render the wrong instants. Mixed-fps sequences are a clear 422 and the film's fps comes from the scenes, not the client. Client passes width/height only.
  • One resolution per film, enforced at registration (compare renders/{rid}/ meta.json w/h/fps to the film's) so the failure is a clear 400 the moment it happens, not a cryptic ffmpeg filter error after the last shot. The scale/setsar filter stays as the belt to that braces (renders with no meta.json).
  • xfade offsets come from ffprobe'd durations (:636 _probe_dur, nominal length as fallback): nominal length can differ from the encoded mp4 by up to a frame, and an absolute xfade offset that's off by a frame shows as a stutter at the join.
  • Shots render silent; the soundtrack is film-level. Concat with per-shot audio needs every shot to have an audio stream (mixed presence breaks the filter), and a film's bed rarely respects shot boundaries anyway. Not silent-by-surprise: the render-start response carries a warnings[] naming any referenced scene whose audio[] won't be carried, and film.js logs them.
  • Existing /render/end mux gained apad before -shortest (:326) — a VO shorter than the shot was truncating the PICTURE. Same fix in the film mux; the M4 audio assert still passes.
  • sequences/ ships (like templates/) but is user-writable, so .gitignore takes sequences/* + !sequences/demo-two-shot.json: the example is tracked, John's own sequences stay out of git status. films/ fully ignored.
  • Film plan + shot registrations persist in films/{id}/meta.json (atomic tmp+rename), not just memory: a film spans minutes of client rendering and /end must survive a server restart underneath it. Only the encode state/log is in-memory (renders model). BLOCKED/REQUESTS:
  • @orchestrator (deploy/, not my lane) — two one-line prod fixes so sequences persist:
    1. deploy/docker-compose.yml: add - SCENEGOD_SEQUENCES=/app/sequences and - SCENEGOD_FILMS=/app/films to environment, and - /opt/scenegod-data/sequences:/app/sequences + - /opt/scenegod-data/films:/app/films to volumes.
    2. deploy/deploy.sh: mkdir -p /opt/scenegod-data/{assets,scenes,renders,sequences,films} and add --exclude films to the rsync. Without these the app still boots and works (it mkdirs both dirs inside the container) but a rebuild eats every saved sequence. Nothing else new is needed in prod: no LLM/MB env, ffmpeg is already in the image.
  • @orchestrator — the :8020 uvicorn is not --reload; restart it before browser-driving the film (it will 404 /sequences). Test instances only ever ran on 8097/8099.
  • @Lane B (whenever it's unlocked) — the UI contract is ready and stable: import { renderSequence, listSequences, getSequence, saveSequence, deleteSequence } from './web/film.js'. A sequence editor is a list of {scene,in,out,transition, transitionDur} rows over GET /scenes; renderSequence(stage, timeline, name, {width, height, onShot, onProgress}) does the rest and resolves with the mp4 URL. NOTE it calls timeline.applyScene() per shot, so it CLOBBERS the loaded scene — the UI should confirm via timeline.hasContent() first, same as new-from-template. NEXT: nothing outstanding in Lane C. Backlog: per-scene audio carried into a film (auto-derive the bed from each shot's scene audio[] offset into film time — needs a silence-input path for shots with no audio); more transition types (xfade has ~50, wipeleft/slideup are one vocab entry each); still-unverified MediaRecorder draft path (carried from session 3).

2026-07-25 session 7

DONE: M9-C1 audio trim at the mux + M9-C2 BEAT DETECTION. 18 test groups green (was 15). OK: 18 test groups passed, whole suite ~6.5s.

M9-C1 — in/out honoured in BOTH muxes.

  • scenegod/server.py:112 resolve_audio() — ONE function now turns an audio[] entry into a muxable dict for both lanes (/render/{id}/end at :441 and plan_sequence at :562), so a clip means the same thing in a shot and in a film. Validates path (guarded), start/gain/in/out types, in>=0, in<out. _num moved up to :103 next to safe_under for the same reason.
  • :364 audio_input() puts the trim on the INPUT (-ss in -t out-in) and :381 audio_filters() keeps the adelay/volume/amix/apad chain EXACTLY as shipped. Both muxes call the pair (:401 render, :718 film).
  • Absent in/out = byte-identical ffmpeg command to before (proved by the untrimmed A/B in the test).
  • Verified end-to-end, not by eyeballing the command line: scripts/test_server.py:250 renders 3s of picture over trim.wav (1s silence, then 1s of 880Hz). Trimmed to the tone at start:0.5 -> volumedetect reads -91dB at 0.0-0.4s, -6.1dB at 0.6-1.4s, -91dB at 1.7-2.7s. The SAME clip untrimmed reads -91dB at 0.6-1.4 and -6.1dB at 1.6-2.4 — i.e. the trim moved the sound, and it is not a tautological test. :429 does the same for the film-level bed (silent to 1.8s, tone at 2.1s) inside the existing dissolve film. DECISIONS (M9-C1):
  • -ss/-t on the input, NOT atrim in the filter graph — this is a bug fix, not taste. atrim,asetpts,adelay,...,amix,apad on ffmpeg 8.x (homebrew, 62.x libs) emits pad frames at ~INT64_MAX PTS; the muxer drops all of them and you get an mp4 with an aac stream and zero audio samples, rc=0, no warning. Reproduced 4 ways: old chain OK, +atrim broken, +atrim without apad OK, +atrim with apad=whole_dur=N OK. Input seeking leaves the proven graph untouched. (Sample-exact for wav; a few ms at worst for mp3.)
  • scripts/test_server.py:59 seg_db() REFUSES to answer when a window decoded no samples, instead of returning -99. Otherwise every "assert it's silent here" passes on a stream that is silent because it is empty — which is exactly the failure above, and it would have shipped green.
  • A trim window past EOF made ffmpeg die mid-mux (-22 Error muxing a packet -> a mystery failed render), so resolve_audio ffprobes the source only when a trim is asked for: in past the end is a 400/422 that says so, out past the end is just no tail trim (a UI dragging the tail wide shouldn't fail a render).

M9-C2 — GET /beats?path= (server.py:869-1090). {path,bpm,confidence,beats[],downbeats[],duration,meter,period,acPeak,onBeatContrast,analyzed}. Chain (all stdlib + one ffmpeg pipe, no new deps):

  1. :896 ffmpeg -> mono s16 PCM @22050 (-map 0:a:0, so mp4/webm work too), read with array. -t 900 cap: 15 min analysed, grid extrapolated past that with truncated:true (a 40MB decode ceiling, not a 2-hour DJ set in RAM).
  2. :908 per-frame log energy, hop 256 (86.13 fps) -> half-wave-rectified first difference -> 3-tap smooth -> local-mean subtraction (±0.37s).
  3. :933 normalised autocorrelation over 60..180 BPM, harmonics added (r[l] + .5r[2l] + .5r[l/2] + .3r[3l/2]), times a log-gaussian prior centred on 125 BPM, peak parabola-interpolated.
  4. :1029 explicit octave check: score half / self / double with the comb, keep everything within 15% of the best, prefer the candidate in 90..180 BPM.
  5. :968 comb-filter phase search over one period, then :972 weighted least-squares refit of period+phase onto the nearest onset peaks (sub-frame, parabolic). Without the refit the raw AC lag is ±0.5 frame = seconds of drift across a 4-minute track.
  6. Emits an absolute-seconds grid across the whole file; downbeats = every 4th beat, 4/4 assumed, coset chosen as the strongest of the four (meter:4 is in the response so a caller knows it was an assumption, not a detection). MEASURED ACCURACY (synthetic click tracks, aevalsrc with mod(t,period) so beat k is at EXACTLY k*period): 12 tempos 90/100/110/118/120/124/126/128/132/140/150/174 -> worst BPM error 0.03 (spec allowed ±2), worst beat-time error 3.3 ms over the first 16 beats (spec allowed ~30 ms), confidence 0.85-0.94. Non-music: pink noise conf 0.10, digital silence conf 0.0 with beats:[] and a note (no crash, 200). Real 90s house from ~/Music/HOUSE: 127.41 / 126.89 / 121.20 / 126.95 BPM, and independently re-analysing two disjoint 90s windows of each track reproduces the full-track BPM to within 0.15 on 3 of 4 (the 4th is an intro/breakdown pair that confidence correctly calls mush, 0.16-0.20). SPEED (ultra, measured): 4-min wav 0.41s, 5-min mp3 0.66s, 7.6-min mp3 0.69s; cached re-hit 2-3 ms. Cache key is (path, mtime_ns, size), bounded at 32. DECISIONS (M9-C2):
  • Energy flux, not spectral flux. A pure-Python radix-2 FFT is ~20k transforms for a 4-min track (tens of seconds of CPU) and the repo forbids numpy; for four-to-the-floor the kick dominates broadband energy anyway. The log compression + local-mean whitening is what buys back the discrimination — it is the difference between conf 0.07 and 0.35 on a dense club mix.
  • confidence = sqrt(acPeak × onBeatContrast), both reported separately so a caller can see why. acPeak = normalised autocorrelation at the chosen period ("is the envelope periodic at all"); onBeatContrast = (on-beat mean all-frame mean)/(sum) ("does the EMITTED grid explain the onsets"). Measured bands, in the code comment: clicks 0.83-0.93, real house 0.32-0.65, noise 0.10, silence 0.0. Read >0.5 as a solid grid, <0.2 as mush.
  • Mean (not sum) comb score, deliberately: a double-tempo grid then pays for the empty positions it adds, which is half the octave problem solved for free. The half/double check + band preference is the other half — verified on alt128 (strong kick every 2nd beat, autocorrelates at 64) -> 128.01.
  • The grid is CONSTANT tempo. No rubato/drift tracking, said plainly in the response contract. For a DJ mix, tempo changes per track — analyse a segment.
  • The two fixtures in assets/audio/test/ are not exact: direct sample-level onset timing puts beat120.wav's kicks at 0.5 … 14.53 (28 intervals -> 119.74 BPM, it drifts ~0.3%), and house128.wav drifts similarly. My detector says 119.62 / 127.62 on them, i.e. it is reading the FILE correctly. That is why the test synthesises its own exactly-periodic clicks instead of asserting against those two.
  • New test group scripts/test_server.py:525 boots an instance with PATH=/usr/bin:/bin (no homebrew) and asserts /beats AND /rhubarb 503 — the ffmpeg gate is now actually exercised, and so is rhubarb's 503 branch, which had been unreachable on this box since rhubarb was installed.
  • No client change was needed: web/render.js:74 endRender already forwards the scene's audio[] objects verbatim, so in/out reach the server as-is. BLOCKED/REQUESTS:
  • @Lane B — /beats is ready for beat-snapping. Contract: GET beats?path=<asset-relative> -> {bpm, confidence, beats:[s,...], downbeats:[s,...], duration, meter:4, period, acPeak, onBeatContrast}; times are absolute seconds in the FILE, so snapping a timeline key means beat + audioClip.start - (audioClip.in||0). Gate the UI on confidence (>0.5 solid, <0.2 mush) rather than pretending every grid is good. First call on a 5-min track is ~0.7s, after that it is instant — fetch once per audio clip and cache client-side.
  • @orchestrator — nothing needed for prod: ffmpeg is already in the image, so /beats works on the VPS; no new env, no new dep (requirements.txt untouched). The :8020 uvicorn still isn't --reload, so restart it before browser-driving /beats. All my testing ran on ports 8097-8104 and 8098. NEXT: nothing outstanding in Lane C. Backlog: per-scene audio auto-carried into a film bed; more xfade transition types; the still-unverified MediaRecorder draft path (carried from session 3); and — if beat-sync gets ambitious — a ?from=&to= window on /beats so a DJ mix can be analysed per track instead of assuming one tempo for the whole hour.

2026-07-26 session 8 — M9-C2 tempo bias: root-caused and fixed

The orchestrator is right and my session-7 claim was wrong. The fixtures are exact (beat120.wav kicks on 0.5000s to the microsecond, house128.wav on 0.468735s), and my detector was 0.32% slow on both. Worse, I had measured 120.002 correctly myself early in that session and then believed a cruder second scan over it — the mistake was disbelieving ground truth to protect the code. Corrected below.

ROOT CAUSE — the onset front-end was measuring the wrong instant. A percussive hit is a high-frequency transient riding a slower low-frequency body. BEAT_SR=22050 resamples away everything above 11 kHz, and in beat120.wav the >11k share of the attack grows ~20x across the file (peak sample 1402 at beat 1 -> 27711 at beat 15). Measured, per beat, on the <11k signal that survived the decode: beats 1/5/29 peak at +0 ms after the attack, beats 15/25 at +30 / +50 ms. A lateness that VARIES per beat is a tempo error, not an offset — and the weighted least-squares refit then fits those biased points perfectly. It was NOT the refit (sub-millisecond over 200s click tracks) and NOT integer-lag AC quantisation (the parabolic step already handled that; the AC's own estimate was 119.87, and the refit moved it to 119.62 because its INPUT peaks were late). Fix: BEAT_SR, BEAT_HOP = 44100, 512 (server.py:882) — same 86.13 fps envelope, same constants downstream, transient preserved. 172 fps was tried and REJECTED (server.py:875 comment): it breaks octave choice on real records (a 90s window of a house track went to 63.7 BPM) because the prior/harmonic weights are tuned for 86 fps. server.py:906 _frame_energy() now decodes+frames in ONE STREAMING pass (Popen, ~4MB reads) — 44.1k × 900s is 79MB of PCM and subprocess.run would hold it twice; peak memory is now a few MB. It also returns the exact sample count, so duration is 15.0 not 14.988.

SECOND BUG, found by sweeping tempos after the fix (server.py:963 _beat_tempo): a 118 BPM / 60s click track read 114.79. Two defects compounding: (1) harmonics that fall outside the lag table counted as ZERO, which silently penalises the lag that owns them (lag 43 kept its 2x at 86, lag 44 lost its 88 — so a hair of score flipped the choice to the wrong lag); (2) the parabolic interpolation was then applied to a point on a SLOPE, which extrapolates: lag 43 became 44.95. Fixed by scoring only the harmonics that exist (weight-normalised), then walking uphill to the local AC maximum before interpolating, and clamping the correction to ±0.5 lag.

RE-VERIFIED against the ground truth (live over HTTP, GET /beats): | file | truth | detected | err | max |beat true grid| | conf | |---|---|---|---|---|---| | beat120.wav | 120.003 | 120.030 | +0.027 | 6.0 ms (last beat +3.3) | 0.92 | | house128.wav | 128.004 | 128.020 | +0.016 | 10.1 ms (last beat +7.6) | 0.86 | Was 0.387 / 0.388 and 45.1 / 52.5 ms of drift. Synthetic sweep, 17 tempos 60…179 BPM on 60s files: worst BPM error 0.010, worst drift 5.7 ms across the whole file. Octave traps still correct (alt-strong-every-2nd 128.01, 64 BPM stays 64, kick+offbeat-hat 125.97). Real 90s house unchanged: 127.41 / 126.89 / 121.22 / 126.96, disjoint-window self-consistency within 0.13 BPM on 3 of 4. Truncation path exercised (cap forced to 60s on a 200s file): grid extrapolates to 200.0s with 1.0 ms of drift at the end. Speed: 4-min wav 0.48s, 5-min mp3 0.84s, 7.6-min 0.78s, cached 2-3 ms (was 0.66s at 22050 — the transient costs ~0.2s and is worth it).

TESTS — this class of error cannot pass again (scripts/test_server.py:468, 18 groups):

  1. Every click case now asserts whole-file drift (max|b round(b/per)·per| < 20 ms), not just average BPM, and the sweep gained 118 BPM @ 60s (pins the _beat_tempo fix).
  2. 200s click track at 128 — end-of-file drift < 20 ms, and the grid must actually reach past 195s (catches "correct tempo, grid stops early").
  3. hftick.wav — a 16 kHz tick over a slow 60 Hz body, i.e. the failure mechanism itself: the first 12 beats must land within 12 ms. This is the one that pins BEAT_SR, and it ships in the test (assets/ is gitignored).
  4. The fixtures as ground truth when present: 120.003 / 128.004 ± 0.2 BPM and < 20 ms drift, copied into the test's asset root, skipped with a printed note when absent. Proven to discriminate, by monkeypatching the old code back in: 22050/256 -> fixtures FAIL at 45.1 / 52.5 ms drift and hftick FAILS at 26.7 ms; old _beat_tempo -> 118 BPM case FAILS at 114.79 (253 ms drift). Note the pure 200s click track passes under BOTH old versions — a clean click track can never catch a spectral bias, which is exactly why session 7's tests missed it. DECISIONS:
  • Ground truth wins. When a measurement disagrees with the code, the code is wrong until a BETTER measurement says otherwise — and "better" means the one with the sharper instrument (sample-level threshold crossing over the whole file), not the newer one.
  • Front-end resolution is a tuning-coupled choice, not a free knob: 44100/512 keeps every downstream constant valid; 44100/256 would need the prior and harmonic weights retuned. The comment at server.py:875 says so, so nobody "optimises" the decode rate back down. BLOCKED/REQUESTS: none. Everything else from session 7 (trim work, speed, caching, confidence, the 503 gate) is unchanged and still green. NEXT: unchanged from session 7 (per-scene audio into a film bed, more transitions, MediaRecorder draft path, ?from=&to= windowing on /beats for DJ mixes).

2026-07-28 session 9 — render QUALITY pass (chrome, colour, supersample, symlinked banks)

Four items, all shipped. 21 test groups green (was 18) + a new client test file. .venv/bin/python scripts/test_server.py -> OK: 21 test groups passed; node scripts/test_render_client.mjs -> OK: render.js client checks passed; node --check clean on render.js + film.js.

C1 — the gizmo/grid/camera-cones were in every mp4, and each render quadrupled the viewport.

  • web/render.js:80,97captureFrames now brackets the loop with stage.pause?.()finally { stage.resume?.() }. Stage's SYNC7 _chrome(false) (stage.js:612) only fires when _paused, and pause() (stage.js:621) is the ONLY thing that sets it — grep proved nothing in web/ ever called it, so _chrome() had been dead code and the TransformControls arrows, the 60x60 GridHelper and the other cameras' cyan cones were baked into every frame. Doing it inside captureFrames covers both callers (Lane B's ⏺ Render and film.js's per-shot loop) exactly once each, and it stops the rAF director loop fighting for the GPU at render size.
  • web/render.js:40-52withRenderSize snapshotted canvas.width/height (DEVICE px) and restored them through setSize(w,h,false) AFTER putting the pixel ratio back — and setSize multiplies by the ratio again. On a retina Mac (dpr 2) the viewport buffer grew 2x per axis per render (4x the pixels after one film, 16x after two) until a window resize. Now snapshots canvas.clientWidth/clientHeight (the canvas is width:100%;height:100% of #view — style.css:24 — so clientWidth IS the CSS size, independent of the buffer), falling back to canvas.width/dpr when the panel is hidden. No three import (header rule holds).

C2 — colour-tagged, faststart, one lossy generation.

  • server.py:408 video_args(crf=18) — one helper, both call sites (_encode :464, _concat_film :813): libx264 / preset slow / high@4.2 / yuv420p / crf / bt709 primaries+trc+ colorspace / range tv / +faststart.
  • MEASURED, and it changed the design: on this box's ffmpeg 8.1.2 the OUTPUT options -color_primaries/-color_trc are SILENTLY IGNORED (only -colorspace lands) — the png decoder + auto-scaler hand the encoder frames tagged "unspecified" and frame properties win. ffprobe on the result: color_space=bt709, color_transfer=unknown, color_primaries=unknown. So server.py:428 SETPARAMS tags the FRAMES (setparams=...) and rides on every encode — appended to the -vf chain in _encode, and as a [vout] node at the end of _concat_film's filter_complex (:805/:807, the map moved from [{cur}] to [vout]). ffprobe now reads bt709 on all three fields, asserted for both the shot mp4 and the film.
  • Double-encode: _encode takes crf=18 by default; film SHOTS pass 14 (server.py:537, driven by {intermediate:true} in the /render/{id}/end body — render.js:104 endRender(rid, audio, extra), set by film.js:48). The concat stays at 18, so only one pass meaningfully quantises. Asserted end-to-end by reading the crf out of x264's SEI (crf=14.0 / crf=18.0 in the mp4 bytes), not by inspecting the command line.

C3 — 2x supersample, resolved by lanczos in ffmpeg.

  • web/render.js:59 ssFactor(w,h,ss=2) (exported; film.js uses it too): 2, forced to 1 when w>1920 || h>1080. finalRender (:123) and renderSequence (film.js:36) render at width*ss × height*ss but still declare the OUTPUT size to render/begin, so meta.json keeps describing the deliverable and the film-grid check is unaffected. Uniform scale = same aspect, and _render only refits backdrops on an aspect CHANGE (stage.js:611) — framing untouched.
  • server.py:431 vf_args(meta) reads the declared w/h from the render's meta.json and emits -vf scale={w}:{h}:flags=lanczos,<setparams>, always (near-noop when sizes already match; also catches a client/server size mismatch). No scale when begin declared no size — the pre-existing {name,fps}-only begin body must not get upscaled to the 1280x720 default.
  • -vf sits before the audio -filter_complex; they coexist because the video map is a plain input (0:v), not a complex-graph label. Verified with a real mux, not by reasoning — the test renders 640x360 frames with a tone and asserts both 320x180 output AND that the audio is still there (-6dB at 0.05-0.25s).
  • server.py:506 frame cap 25MB -> 40MB with the reason in a comment. Every other capped_body limit untouched.

C4 — symlinked asset banks (the documented way to get banks in) stop 400-ing.

  • server.py:95 safe_under is now LEXICAL: normpath (backslashes -> /), reject absolute / .. / ../…, return root / rel UNRESOLVED. .resolve() follows symlinks, so ln -s ~/MESHGOD/assets3d/gallery assets/props/gallery listed in /assets/tree and then 400'd "bad path" on every fetch (dock: failed: Error: asset fetch 400) — characters, backdrops, clips, audio, /beats, /rhubarb, all of it. Traversal is still caught before touching the disk (normpath collapses a/../../x to ../x). The docstring says all of this so a future ship-check pass doesn't "fix" it back, and notes the accepted symlink-loop caveat.
  • server.py:178 os.walk(base, followlinks=True) so a bank symlinked one level deeper is scanned at all.
  • Side effect worth knowing: the lexical guard also rejects ..\..\x, which the old resolve()-based guard ACCEPTED on posix (backslash isn't a separator there, so it was just a weird filename inside the root). Harmless either way; strictly safer now.

TESTS — all four proved to DISCRIMINATE by putting the old code back:

  • scripts/test_render_client.mjs (NEW, plain node, no deps, no browser, no three): fake Stage / Timeline / fetch. Old render.js -> 9 failures (pause/resume never called, renders outside the pause window, restore at 2560x1440 instead of 1280x720, hidden-panel fallback). Also asserts: 30 frames for 1s@30fps, one POST per frame, render/begin declares 1280x720 while setSize gets 2560x1440, ss forced to 1 above 1080p, ssFactor thresholds, and that resume() still runs when a frame POST throws.
  • scripts/test_server.py:145 safe_under unit (6 traversal rejects, 3 accepts incl. empty=root)
    • integration (symlinked bank in /assets/tree and streamable, exact bytes). Old code: followlinks off -> symlinked bank not scanned; old guard -> (400, b'{"detail":"bad path"}').
  • :172 video_args/vf_args unit: bt709 on all four fields, +faststart, crf 14 vs 18, no input-only flag (-i/-framerate/-ss/-t/-f/-y) in the output list, setparams on every encode.
  • :298/:308 render smoke: colour tags on out.mp4; 640x360 frames -> 320x180 output; the -vf + -filter_complex coexistence with audio; crf SEI check. :513 the FILM is bt709 too. Old encoders -> AssertionError: unknown (primaries) and ('640','360') (scale). DECISIONS:
  • 1080p still supersamples. The item's prose said force ss=1 when w>1920||h>1080; its sample test line said a 1920x1080 render should get ss=1. Those contradict, and the prose wins: the default deliverable IS 1080p, so ss=1 there would make the whole feature a no-op for the common case — and the same item's 40MB-cap rationale explicitly assumes "a 1080p deliverable posts 3840x2160 PNGs". So >1080p output = no supersample; 1080p and below = 2x. Written into the test as an explicit assert with that reasoning.
  • crf is not a free client knob: /render/end takes a BOOLEAN intermediate, and the server picks 14 or 18. A client can't ask for crf 51.
  • vf_args skips the scale when begin declared no size rather than defaulting to 1280x720 — the existing test posts 64x64 frames with a {name,fps}-only begin body and must not be upscaled. Tags still ride on that path. BLOCKED/REQUESTS:
  • @Lane B (tlui.js, FYI not a blocker): renderPlan()'s object is spread into finalRender, so adding ss: 1 to it is all that's needed if you ever want a "fast draft" toggle — the option already exists client-side. Also note a 1080p render now uploads 3840x2160 PNGs: expect it to take roughly 2-4x longer than before and consider saying so on the button.
  • @orchestrator — nothing needed for prod: no new env, no new dep, ffmpeg in the image already has setparams (filter exists since 4.1). The :8020 uvicorn is still not --reload, so restart it before browser-checking any of this. My tests ran on ports 8097-8104 only. NEXT: unchanged backlog (per-scene audio auto-carried into a film bed; more xfade transition types; MediaRecorder draft path still unverified in a real Chrome window; ?from=&to= windowing on /beats). New candidate: draftRecord should pause the stage too — it deliberately does NOT (it records the LIVE canvas, so chrome is arguably wanted in a draft), but if John disagrees it is a two-line change.

2026-07-28 session 10 — adversarial-review fixes (colour was a LIE, gizmo ghost, dropped frames)

23 test groups green (was 21) + 4 new client groups. .venv/bin/python scripts/test_server.py -> OK: 23 test groups passed; node scripts/test_render_client.mjs -> OK: render.js client checks passed; node --check clean on render.js, film.js, test_render_client.mjs.

MAJOR 1 — session 9's colour work TAGGED the file bt709 and CONVERTED the pixels bt601. Reproduced with the shipped arg list before touching anything: pure green PNG in, first luma sample out = 145 (bt601 green; bt709 is 173), file decodes through its own bt709 tag as (0,216,0). Neon magenta (230,40,120) -> (244,61,119). Root cause is NOT "we forgot a flag": a setparams filter PINS ITS OUTPUT LINK to bt709, which leaves the scale filter's output link unspecified, so swscale's RGB->YUV falls back to bt601 — with no setparams at all the graph negotiates bt709 off -colorspace and gets it right. i.e. session 9's tag caused the mislabel it was meant to cure. Fix = state the conversion on the converting filter: server.py:441 CONVERT = "out_color_matrix=bt709", used by vf_args (:463, both the sized and the no-size chain) and by _concat_film's per-shot scale (:850). Measured after: Y=173, round-trip (0,255,1) / (230,38,119).

  • The reviewer's sub-claim that the NO-SIZE path was equally broken did not reproduce: setparams-only was already correct (the auto-inserted scaler sits AFTER setparams and honours the tag). Made it explicit anyway — scale=out_color_matrix=bt709 keeps iw:ih — so correctness stops depending on where ffmpeg chooses to insert its scaler. Test pins that it does not resize.
  • Regression: test_server.py probe_rgb() decodes frame 0 back to RGB and near() compares. Three colours (green / neon magenta / sky) x both filter paths, plus the FILM (its untagged lavfi fixtures are exactly the "old shot mp4 still in renders/" case). Discriminates: with CONVERT removed -> 0x00FF00 scaled render decodes as (0, 216, 0); film -> film decodes green as (0, 216, 0). Tag asserts alone stay green through all of that — that is why they missed it.

MAJOR 2 — pause() woke up dead code that left a GHOST GIZMO in the viewport. stage.js _chrome(true) (Lane A) re-shows chrome after every paused frame by FORCING _tc.visible = true, overriding the visible = false that select()'s detach() just set. So a render started with nothing selected (or with a frustum-fitted plate selected — same detach) ended with an inert translate gizmo floating at the last object's position: the exact bug class pause() was added to kill, re-introduced by pause() itself.

  • Fix in my file: render.js:81 restoreSelectionChrome(stage), called from captureFrames's finally (:139). Re-runs select() with the id Stage already holds, so attach/detach decides the gizmo again. It re-selects only if stage.getEntity(id) still resolves — film.js swaps the whole scene per shot, so the remembered id is often gone and re-attaching to a dead entity would be worse than no gizmo.
  • Regression: client group 7 models stage.js exactly (attach/detach drive _tc.visible, _render brackets each paused draw with _chrome(false)/_chrome(true)); three cases — nothing selected, something selected, selection since deleted. Discriminates: dropping the call -> 2 FAILs.

BONUS (nobody caught this one) — a failed frame POST was SILENTLY DROPPED. Found while wiring the new tests: the .finally(() => inflight.delete(p)) pulls each POST out of the set as it settles, so a rejection nobody happened to be awaiting vanished. Measured with only frame 3 failing: captureFrames RESOLVED, 29/30 frames stored, plus an unhandled rejection — ffmpeg's %06d demuxer then stops at the gap and you get a short mp4 with state=done. Fixed at render.js:129 (.catch remembers the first error) + :132/:136 re-throw, so it fails fast at the frame that broke. Regression = client group 5b + a process-wide unhandledRejection sentinel (group 10). Discriminates: old code -> 3 FAILs (resolves, no frame number, 29 unhandled).

MINOR — vf_args' scale silently UPSCALED an undersized capture (docstring claimed the opposite: "catches any client/server size mismatch"). Now _encode ffprobes frame 0 (_png_size, server.py:466) and errors before spawning when the frames are SMALLER than the declared deliverable — the lost-WebGL-context / clamped-canvas case, previously a soft mushy mp4 with state=done and no log line. Bigger is normal (that is the supersample); equal is fine. Docstrings now describe what the code does. Discriminates: guard disabled -> undersized frames encoded anyway: {'state': 'done'...} with 64x36 frames upscaled to 320x180.

MINOR — pause/resume were optional-chained, so losing them would fail silently. They are not in PLAN §4.2's Stage contract and stage.pause?.() no-ops on any Stage without them, which would put the gizmo back in every mp4 with all tests green. Now REQUIRED (render.js:116 throws a message that says why), and client group 9 reads Lane A's live web/stage.js and fails if pause()/resume()/the _paused chrome gate/_selected stop existing — the first test in this repo that pins a cross-lane seam the PLAN doesn't cover. Discriminates: reverting to ?. -> FAIL; and the stage.js regexes go false on a mutated copy with pause() renamed.

DECISIONS:

  • A tag is not a conversion, and in ffmpeg they FIGHT: setparams breaks the colourspace negotiation that would otherwise do the right thing. Both halves are now written next to each other with the measurement in the comment (server.py:428-441) so neither gets "cleaned up".
  • Colour assertions are per-PIXEL from here on. Session 9's tests asserted the metadata the code writes, which can only ever prove the code did what it says, never that the result is right.
  • restoreSelectionChrome reads stage._selected (a private field) rather than a getter, because there isn't one. It is guarded by '_selected' in stage, so a Lane A rename degrades to "no restore" rather than clearing John's selection — and group 9 fails loudly if that field goes. BLOCKED/REQUESTS:
  • @Lane A (stage.js — DO NOT let me edit it): _chrome(visible) forces visible = true on everything it touches, so it cannot restore state it did not save. _chrome(true) un-hides the DETACHED TransformControls (three r160: attach()/detach() drive .visible directly). Please snapshot each object's visible in _chrome(false) and restore those values in _chrome(true); then my restoreSelectionChrome guard in render.js becomes belt-and-braces and can go. Same latent hole covers en.helper and anything else that is legitimately hidden at render time.
  • @orchestrator (deploy/ is not mine): prod nginx client_max_body_size 30m (deploy/nginx-scenegod.conf) is now BELOW this server's 40MB frame cap (server.py:552) and its comment ("render frames <=25MB + slack") is stale. Nothing hits either wall today — 4K PNG frames measure ~3MB typical — but the binding limit in prod is the one Lane C cannot see, and a 413 there arrives with no message from us. Please bump to 48m and refresh the comment.
  • @orchestrator, disclosing the FULL cost of session 9's ss=2-at-1080p deviation (I logged the deviation but not its bill): the default ⏺ Render posts 3840x2160 PNGs, so /opt/scenegod-data/ renders holds ~2.6x the bytes for the 48h reap window (a film keeps every shot's frames), the upload over the tunnel is ~2.6x, and the default render is 2-4x slower. There is no UI opt-out because renderPlan() (tlui.js, Lane B) emits no ss — the option exists client-side, it just is not wired to a control. If you want the acceptance line honoured instead (1920x1080 -> ss 1), it is one number in ssFactor and one assert in test_render_client.mjs; say the word and I will flip it rather than defend it. NEXT: unchanged backlog (per-scene audio auto-carried into a film bed; more xfade transition types; MediaRecorder draft path still unverified in a real Chrome window; ?from=&to= windowing on /beats). New candidate off this session: _encode could also refuse a frame COUNT that does not match duration*fps — the same invisible-failure family as the undersized-frame guard.