diff --git a/lanes/B-timeline.md b/lanes/B-timeline.md index bedad6d..7d2215f 100644 --- a/lanes/B-timeline.md +++ b/lanes/B-timeline.md @@ -243,3 +243,19 @@ Ruling on your open question: MOVE_MAX_DUR 6s is a soft ceiling — extending up to land a bar-snapped move on a downbeat is correct and worth the overshoot, since the scene-end clamp still applies. Lane A can add the cut checkbox whenever it next touches the DIRECT bar; the model honouring `cut:true` already is the right shape. + +### 2026-07-26 — ROUND 3 (workflow): orchestrator fix, dangling camera cuts. +Adversarial review found that "delete a camera, one Ctrl+Z, scene is unsaveable" was closed only +for the single history it was reproduced on. It is not winnable that way: deleting a camera spans +Stage (no undo) and Timeline (undo), so ANY older entry — a removeCut, or an addCut restoring the +`prev` it replaced — resurrects a cut for a camera that is gone. Two real histories were +demonstrated against the REAL server validator. +Orchestrator fix, in timeline.js: enforce the invariant at serialization instead of policing +history. `liveCameraIds()` / `danglingCuts()`; `toJSON()` prunes cuts naming a non-camera and says +so; `cutCameraAt()` skips them so playback holds the last LIVE camera instead of chasing a dead id. +History-independent, cheap, and it is the same rule the server applies. +Also: the assertion in stage_test.mjs that claimed to cover this was VACUOUS — it drained a +hand-built stack whose only entry was a seeded no-op, so it could not fail for the reason it named. +Replaced with a note; timeline_test.mjs now owns the honest version, replaying both real histories +and asserting nothing dangling survives toJSON. Proven to discriminate (reverting toJSON → RED). +LESSON worth keeping: when a test asserts a property, check the fixture can actually violate it. diff --git a/logs/laneA.md b/logs/laneA.md index 7538c62..31753d0 100644 --- a/logs/laneA.md +++ b/logs/laneA.md @@ -541,3 +541,442 @@ NEXT / orchestrator SYNC checklist (browser, :8020): `scenes/music-video.json` (4 cams) is the scene to sanity-check that nothing else shifts. 5. Load a plate with `fit: frustum` → inspector shows **anchor** under `fit dist`; drag it 0.75→0.4 and the plate slides down live (horizon drops); 1.0 puts its top edge on the lens axis. + +## 2026-07-28 session 12 — LOOK PASS: tone mapping, shadows, frame guide, dead controls + +DONE (5 items, best-first, all shipped): + +**A1 — ACES tone mapping + unlit backdrop plates + per-preset exposure.** +`stage.js:126-130` sets `toneMapping = ACESFilmicToneMapping`, `toneMappingExposure = 1.0`. +grammar.js ran physical sun intensities of 1.2–3.4 into `NoToneMapping`, so every lit face +clipped to 255,255,255 and a warm key went neutral white. +Plates stopped being re-lit: `_buildBackdrop` (`stage.js:400-420`) builds the wall with +`MeshBasicMaterial({map, fog:false, toneMapped:false})`, the dome material gained `toneMapped:false` +(`:404`), and so did the `screen` material (`:441`). The corner-mode ground copy is now built as its +own `MeshStandardMaterial` (`:414`) rather than `mat.clone()` — it still receives the character's +shadow, which is the only reason it stays lit. `toneMapped:false` is load-bearing on all of them: a +photo/Flow frame is display-referred and ACES over an already-graded image muddies its blacks. +Plate level is `params.plateExposure` (default 1) via `_plateLevel()` (`stage.js:487`), wired in +`setParam` next to `fitDist` (`:640`) and exposed as a 0–2 number field in the dock's backdrop block +(`dock.js:249`). +`exposure` added to every LIGHTS entry (`grammar.js:38-52`: day 1.0, golden_hour 1.1, night 1.6, +noir 1.2, horror 1.4, neon 1.3, overcast 1.0). `applyLight` writes it exactly like `bg` +(`presets.js:174`) and returns it (`:178`), so it rides the ambient entity → keyable, persists in +scene JSON. `Stage.setParam` handles `exposure` on a light entity (`stage.js:632`) through +`clampExposure` (exported, `stage.js:68`, clamps 0.05–4 so a keyed lerp through 0 can't black the +frame); `_buildLight`'s ambient branch applies `p.exposure ?? 1` on build (`:567`), and +`removeEntity` puts the renderer back to 1.0 when the ambient goes (`:249`) so loading a scene with +no ambient doesn't inherit the last one's level. Inspector also got an `exposure` field on the +ambient light next to `fog` (`dock.js:229`). + +**A2 — a stage that actually casts shadows, with a box that fits the shot.** +`this._defKey` now casts (`stage.js:164-166`): until you added a light entity NOTHING cast a shadow +despite `shadowMap.enabled`, so characters hovered on an untouched stage. `shadowMap.type = +PCFSoftShadowMap` (`:125`). New `_shadowRig(dir)` (`stage.js:216`) is the one config for the default +key AND scene keys: `castShadow`, `mapSize 2048²`, `normalBias 0.02`, `bias -0.0005` — applied in +`_buildLight` (`:569`). +`shadowHalfExtent(bboxes, margin=6)` exported (`stage.js:73`) + private `_fitShadows()` (`:227`) +which sets `camera.left/right/top/bottom = ±h`, `near 0.5`, `far = boomDistance + 2h` and +`updateProjectionMatrix()` on `_defKey` and every directional light entity. Cost control per spec: +`_shadowDirty` set in `addEntity` (`:317`), `removeEntity` (`:252`), `setTransform` of a light +(`:626`), `setParam` of a light (`:630`), `_buildLight` (`:580`); consumed once per frame at the top +of the rAF loop (`:198`), BEFORE the `_paused` early-return so a paused render still gets a fit. +Key-light proxy sphere got `userData.chrome = true` (`stage.js:576`) — without it yellow spheres +would render into the mp4 the moment Lane C calls `pause()`. + +**A3 — compose in the real frame.** +(a) `Stage.directorCamState()` (`stage.js:824`) returns `{pos, rot, fov}` off `_dirCam`; +`dock._addRig` uses it for a new camera's transform + fov (`dock.js:190-194`) and the button is now +`+ camera (from view)` with a tooltip (`dock.js:40`). `setActiveCamera(en.id)` unchanged. +(b) `⬚ frame guide`: a DOM overlay built in the Stage constructor next to the PiP +(`stage.js:143-153`) — four black bars + rule-of-thirds + 90% action-safe hairlines at 25% opacity, +`pointer-events:none`, `z-index:3` (PiP is 5, so the PiP stays clickable). `setFrameAspect(a)` / +`setFrameGuide(on)` / `frameGuide()` / `frameAspect()` at `stage.js:207-213`; `_layoutGuide()` +(`:196`) recomputes from the pure `guideBars()` and is called from `_resize()` (`:194`). Styles +`style.css:83-93`. Toggle is a `⬚ frame` button in presets.js's `lens` segment, default ON +(`presets.js:340-346`), guarded on `stage.setFrameGuide` existing so a stub Stage can't break the +panel. It is pure DOM and NOT tied to `_chrome()`, so it can never reach a captured frame. + +**A4 — a fitted backdrop stops offering controls that do nothing.** +(1) `renderInspector` replaces the nine pos/rot/scale fields with one muted line +(`dock.js:209-217`, `.inote` style `style.css:71`) when `kind==='backdrop' && params.fit==='frustum'` +— `setTransform` hard-returns for exactly those, so those fields were silently eating input. The +`fit` select sits right below and already re-renders on change. +(2) `Stage.select()` (`stage.js:594-598`) detaches instead of attaching the gizmo for a +frustum-fitted plate; selection/inspector/onSelect all still fire. `setParam('fit', …)` re-runs +`select()` on the selected entity (`stage.js:637`) so switching frustum⇄free swaps the gizmo +immediately rather than on the next click. +(3) `videoTimeAt(t, start, dur)` exported (`stage.js:87`) and used by `syncVideos` (`stage.js:797`). +It used to WRAP (`((t-start)%d+d)%d`) while `Timeline._evalVideo` clamps — so a plate with +`videoStart:2` on a 10s clip showed frame 0 in the viewport at t=0 and the 8.0s frame in the mp4. +Same clamp both sides now: what you scrub is what you render. + +**A5 — destructive delete asks first; the dock stops calling every failure "server offline".** +(1) `dock.js:288-303`: counts `transform+params+morphs` keys and `clips` blocks off +`window.timeline.scene.entities`, and only `confirm()`s when `keys+clips > 0`. A freshly added, +unkeyed entity is still one click. With no timeline mounted there is no count, so it confirms for +characters/props and never for cameras/lights, per spec. +(2) `load()` (`dock.js:26-43`) now separates `!r.ok` (keeps `this._loadErr = status + ': ' + body`) +from a thrown fetch (`this._offline`). `renderBrowser` (`:112-122`) shows the server's own message +(escaped with `esc()` — ship-check) for the first, "server offline" only for the second, and the +populated-empty state is now `no on the server yet — drag a .glb / .fbx / image from your +desktop onto the stage` instead of an instruction you cannot follow from a browser. Tab buttons +carry a live item count on a second line (`dock.js:51-54, 108-110`; `style.css:35-38`) so an empty +category is visible before you click it — a count appended inline overflowed the 65px tab. + +VERIFICATION (headless, exact output): +``` +$ node scenegod/web/presets_test.mjs +presets_test: ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°, zoom 35→50mm at a fixed 1.61m) +$ node scenegod/web/stage_test.mjs +stage_test: ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m frame at 14m; shadow box +±6.95m solo / ±10.39m for a two-hander; 1600×600 gate bars 266.7px) +$ node scenegod/web/room3d_test.mjs +room3d_test: ALL PASS (26 CC bones paired against mixamo) +``` +`node --check` clean on stage.js, dock.js, presets.js, grammar.js, stage_test.mjs, presets_test.mjs. +Lane B's `timeline_test.mjs` re-run read-only: `OK — timeline_test.mjs: all assertions passed`. +New assertions (all fail on the old code): +- presets_test: every LIGHTS key has a finite `exposure` in [0.5,2]; night/horror exposed ABOVE day + (the whole point); `applyLight(stub,'noir')` records `setParam(ambientId,'exposure',1.2)` on the + existing stub and the returned object carries it. +- stage_test `shadowHalfExtent`: empty → 4; one 1.7×0.6m character → exactly half-diagonal + margin + (6.95) and already > three's default ±5; a `covers()` helper asserting EVERY bbox corner sits + inside ±h for a two-hander 8m apart (10.39) and for a lone character 8m off-axis (10.25 > 8.3); + margin monotonicity; the 4m floor minimum. +- stage_test `guideBars`: 1600×600@16:9 → left/right (1600−1066.67)/2 and zero top/bottom; + 800×800 → top/bottom (800−450)/2 and zero left/right; 1920×1080 → all zeros; three zero-size + viewports → zeros not NaN; plus a `gate()` check that the remaining rectangle is EXACTLY 16:9 at + four viewport shapes (that's the assertion with teeth — bar thicknesses alone could be self-consistent + and still wrong). +- stage_test `videoTimeAt`: 0/2/10→0, 3/2/10→1, 13/2/10→1, dur 0 and dur NaN → 0, plus an explicit + `notEqual` against what the old wrap returned (8.0) at t=0. +- stage_test `clampExposure`: 0→0.05, −3→0.05, 99→4, undefined→1.0 (old scenes unchanged), '1.6'→1.6. + +DECISIONS: +- **The 10.7m shadow-length rationale in the A2 brief is wrong, and I did not write that assertion.** + A directional light's shadow lies EXACTLY behind its caster in light space — the shadowed ground + points are the caster's own projection along the light direction — so a low sun does not widen the + ortho box laterally at all, it only pushes DEPTH (near/far). Checked numerically at golden_hour's + 9° elevation: the character's top (0,1.7,0) and the shadow tip (10.73,0,0) both project to light-Y + 1.678. Asserting "≥ 10.7 so the shadow is not guillotined" would have been a test that passes for + a reason that isn't true. The real lateral defects are (a) entities spread wider than ±5, and (b) + the box being centred on the light→target axis, i.e. on the STAGE ORIGIN, not on the entities. + So `shadowHalfExtent` unions the bboxes **with the origin** before taking the half-diagonal — a + one-word change to the briefed formula that is a genuine fix: a lone character standing 8m + off-axis gets 6.95 (clipped!) under the centre-relative version and 10.25 (covered) under this one, + and that pair is asserted. `far` is where the low sun actually needed help: `boomDistance + 2h`. +- **Only a LIGHT move sets `_shadowDirty` in `setTransform`** (`stage.js:626`). The brief also + listed characters/props implicitly via add/remove, which I kept — but the timeline re-applies + EVERY entity's rest transform each tick, so flagging characters there would run a + `Box3.setFromObject` sweep over skinned meshes every single frame. Marked `ponytail:` in situ. +- **The corner-mode ground is a fresh MeshStandardMaterial, not `mat.clone()`.** Once the wall + became MeshBasic, cloning it would have silently made the shadow-catching ground unlit too. +- `plateExposure` clamps 0–4 in `_plateLevel` and a non-finite value falls back to 1 (a NaN from a + half-typed inspector field would otherwise paint the plate black). +- `select()` on a fitted plate DETACHES rather than hiding the gizmo, so `_tc.dragging` can never be + true for it — the pick path and `mouseUp`→`onChange` are untouched. +- Frame guide is deliberately NOT wired to `_chrome()`: `_chrome` is a WebGL-visibility switch and + the guide is DOM, so there is nothing to hide. It also can't be captured by `readPixels`/`toBlob`. +- Contracts preserved: Stage API (PLAN §4.2) is additive only — `directorCamState`, `setFrameAspect`, + `setFrameGuide`, `frameGuide`, `frameAspect`, plus the pure exports `shadowHalfExtent`, + `guideBars`, `videoTimeAt`, `clampExposure`. `scenegod:direct` (shot/light/mark/move), + `scenegod:clipdrop`, `scenegod:capturekey`, `syncVideos`/`entityVideo`, scene-JSON round-trip and + relative URLs all unchanged. `exposure` / `plateExposure` are ordinary `params` entries so they + round-trip through `captureState`/`applyState` and are keyable with no schema change. + +BLOCKED/REQUESTS: +- **@Lane C (small, optional):** the frame guide defaults to 16:9. If `render.js` knows the delivery + size, one call — `stage.setFrameAspect(width / height)` before a render setup — makes the guide + match what will actually be encoded. Purely cosmetic; nothing breaks without it. +- **@Lane B (FYI, no action needed):** `Timeline._evalVideo`'s clamp is now the shared definition — + `stage.videoTimeAt(t, start, dur)` is exported if you'd rather import it than keep the inline copy. +- @orchestrator: `exposure` (ambient light param) and `plateExposure` (backdrop param) are new + keyable scalars worth a line in PLAN §4.1's params-by-kind note when you next touch it. +- Still owed by John: a viseme-bearing test character (carried from session 4). + +NEXT / browser checks the orchestrator must do at :8020 (nothing here is DOM-testable headlessly): +1. **Look pass.** Load `scenes/sync2-sunset` (or any old scene). Every preset button in turn: `noir` + must now show the PLATE (it used to black out at ambient 0.15) while the character stays hard-lit; + `night` and `horror` should read as lit-but-dark rather than as holes; `day` should no longer clip + faces to flat white. Ambient inspector now has `exposure` — drag it and the whole frame should + ride, live. +2. **Shadows.** Fresh page, drop ONE character, no lights: he should now have a shadow (he never did). + Then two characters ~8m apart → both shadows present and neither sliced off in a straight line. + `golden_hour` → long soft shadow, no acne bands on the body. +3. **Frame guide.** Black bars + thirds + safe box on load; `⬚ frame` in the DIRECT bar toggles them; + drag the timeline panel taller/shorter (or resize the window) and the bars must re-fit. The PiP + must still be clickable through/over the guide. Then render an mp4 and confirm the bars are NOT + in it (they're DOM, but confirm once). +4. **+ camera (from view).** Orbit to a framing you like → `+ camera (from view)` → the PiP shows + the SAME framing (it used to spawn at [0,1.6,4] looking down −Z). +5. **Fitted plate.** Select a `fit:frustum` backdrop → the inspector shows the muted "placed by the + camera…" line instead of pos/rot/scale, and there is NO gizmo. Switch `fit` to `free` → nine + fields and the gizmo come back immediately. New `plate exp` field: 0.5 should visibly darken it. +6. **videoStart.** Video plate, `vid start = 2`, scrub to t=0 → the plate's FIRST frame (not 8.0s), + and the rendered mp4 must match frame-for-frame at t=0. +7. **Delete + dock errors** (item A5 is browser-only by design, no DOM harness): + (a) add a character, delete → no dialog; (b) key it twice, delete → dialog naming "2 keyframes"; + (c) stop the server, reload → "server offline"; (d) run a scratch server on ANOTHER port with + `SCENEGOD_ASSETS=/nope` → the dock shows the server's own "asset root missing: … (set + SCENEGOD_ASSETS)" text, not "offline". Tab buttons should read CHARACTERS/2 style counts. + +## 2026-07-28 session 8 — adversarial-review fixes (6 majors + 4 minors) +An independent reviewer audited session 7 and confirmed six majors and five minors. Every one was +REPRODUCED first with a scratch harness (`scratchpad/laneA5/repro.mjs`) that loads the SHIPPED +files — stage.js/dock.js with their three/room3d imports stripped and a stubbed `globalThis.THREE` +so real Stage/Dock methods can be called via `Object.prototype.call`, plus Lane B's REAL +`Timeline` for anything that crosses the seam. That harness is now the pattern the new assertions +use, so these fixes are testable headlessly instead of "browser-only by design". + +DONE (defect → root cause → fix; every fix has an assertion that goes red when reverted): + +1. **`exposure` was advertised as keyable and nothing keyed it.** Reproduced: `day` at t=0, + `night` at t=5, scrub back → the ambient's param keys were `color/intensity/bg` ×2 and exposure + stayed 1.6 over the whole day stretch (viewport AND mp4, then saved). Root cause: Lane B's + `applyDirect` light branch keys sun colour/intensity/transform + ambient colour/intensity/bg, + and the exposure was only ever pushed to the renderer. Fix in `presets.js applyOps` (`:271`): + after the normal light payload, fire a SECOND op in the handler's documented GENERIC shape + (`{op:'light', entityIds:[ambientId], params:{exposure}}` — the `else` branch of + `if (d.sunId != null && d.sun)`), so it lands as an ordinary param key at the playhead with no + Lane B change. Verified end-to-end against the real Timeline: keys `exposure@0` + `exposure@5`, + scrub to 0 → 1.0, scrub to 5 → 1.6. Cost: two undo entries per preset press instead of one — + REQUEST below asks Lane B to fold it into the light branch, after which this stays correct. +2. **`+ ambient` / deleting an ambient reset the whole frame to 1.0.** Reproduced: 1.6 → 1.0 on + add, still 1.0 after deleting the new one. Root cause: two places wrote a RENDERER GLOBAL from + whichever light was built/removed last. Fix: one rule, `sceneExposure(entities)` (`stage.js:80`, + exported+pure) = the last ambient that actually STATES an exposure, clamped; `_applyExposure()` + (`:656`) is the only writer and runs from `addEntity`, `removeEntity` and `setParam('exposure')`. + `_buildLight` no longer touches the renderer at all. +3. **The ⬚ frame guide was geometrically wrong on a narrow viewport.** Reproduced: 900×700 @16:9 + → 96.875px bars top and bottom, while the vertical half-angle of the render and of the viewport + are IDENTICAL (0.4142 at 45°) — the mask hid content that is in the mp4. Root cause: the guide + assumed the render loses height; three's fov is VERTICAL and `_render` only rewrites + `cam.aspect`, so the render can only gain/lose WIDTH. Fix: `guideBars` (`:143`) returns + left/right bars only — top/bottom are now always 0 — plus `overscan` (delivery ÷ viewport + aspect) which `_layoutGuide` (`:267`) turns into a `.over` class; `style.css` marks the gate + edges dashed with "the render is wider than this viewport — its sides are off-screen". +4. **corner plates showed one photo at two levels across the fold.** Reproduced: wall + `MeshBasicMaterial toneMapped=false`, ground `MeshStandardMaterial toneMapped=undefined` + (→ three's default true) — lit + tone-mapped below the fold, neither above it. Root cause: the + ground was a different material MODEL because it was doing double duty as the shadow catcher. + Fix: `_plateMat()` (`:566`) is now the ONE factory for every surface showing the plate (wall, + corner ground, dome), and the shadow lands on a dedicated `THREE.ShadowMaterial` catcher + (`:455`) floated 2mm over the ground copy. `setParam('plateExposure')` now only collects + map-bearing materials — `setScalar(1)` on a ShadowMaterial would have erased the shadow. +5. **Deleting an unkeyed camera stranded its cut → every later save 422s.** Reproduced: the old + `ask = keys+clips > 0` rule with a `cameraCuts:[{t:0,camera:'e1'}]` model → no confirm, one + dangling cut; and `removeEntity` left `_activeCamId` on the dead id. Fix: `deleteWarning(tl, en)` + exported from `dock.js:19` counts cuts as work, names them in the dialog and hands the cut + OBJECTS back; the delete handler (`dock.js:326-335`) drops them through the public + `timeline.removeCut` inside one `group()`. `stage.removeEntity` (`:369`) falls back to the + director cam when the active camera is the one being deleted. +6. **`videoStart` still meant two things.** Reproduced: inspector edit → model 0 / stage 2 → + viewport showed the 3.0s frame and the render the 1.0s frame at t=3. Root cause: the inspector + wrote only to the stage while `Timeline._evalVideo` reads the timeline's own params snapshot + (taken once by `_mirror`). Fix: every inspector param edit goes through `Dock._param` + (`dock.js:237`) = `stage.setParam` + Lane B's own `syncFromStage()`. Now 1.0 vs 1.0. + +MINORS fixed: +- **Per-frame shadow sweep.** Any light param key set `_shadowDirty`, and `_evalParams` re-applies + every keyed param every tick → `Box3.setFromObject` over every character, every frame. Now + `shadowAffectingParam(key)` (`:93`, castShadow/type only) and `samePos()` (`:96`) gate it, so a + keyed sun re-applying the same pose is free. The refit was also moved OUT of the rAF loop into + `_render` (`:842`) — the render path drives `_render` directly while paused, so the fit is now + deterministic per drawn frame instead of dependent on rAF timing. +- **shadowHalfExtent's premise is now enforced, not assumed.** The light's target was a CHILD of + its wrapper at z=−1, so gizmo-moving a light carried the light→target axis (and therefore the + ortho box) off the stage. `_buildLight` (`:643`) parents the target to the SCENE at the origin: + a sun's aim IS its position, which is exactly the boom `applyLight` solves. +- **`setFrameAspect` was dead code.** Now wired: `aspectFromSize()` (`presets.js:15`) parses + tlui's render-size selector value and `mountDirectPanel` (`:368`) reads `select.rsize` (delegated + `change` listener + a deferred first read, because the timeline mounts after the DIRECT panel). +- **Implicit global `view`.** All three constructor appends use `this.view`; a static assertion in + stage_test keeps it that way. + +DECISIONS: +- The exposure key rides the GENERIC op shape rather than me calling timeline code directly: the + lane contract is "you apply nothing to the timeline, B keys it", and the generic branch is a + documented part of the same handler. The one cost (2 undo entries) is disclosed, not hidden. +- Camera-cut cleanup DOES call `timeline.removeCut` from dock — deleting an entity has no event + contract, and the alternative was leaving a scene that cannot be saved until Lane B ships. It is + idempotent: if `_mirror` starts dropping cuts (REQUEST below), `w.cutObjs` is simply empty. +- `Dock._param` calls the whole `syncFromStage()` rather than poking Lane B's model per key. It + also copies transforms, which is exactly what `save()` already does, and rest transforms are + only read for entities with no transform keys. +- The corner ground could have gone the other way (make the wall lit too) — that would re-open the + bug where `noir` (ambient 0.15) blacks a photograph out. Display-referred plate + separate + catcher keeps both properties. +- Reverting each fix individually was checked: 13 of 14 reverts turn the new assertions red. The + one that does not is re-adding the old `_buildLight` exposure write while `_applyExposure()` is + still in `addEntity` — the stomp is immediately corrected in the same synchronous call, so the + defect genuinely does not occur in that configuration. That is why the write was REMOVED from + `_buildLight` rather than guarded: one writer, one rule. + +BLOCKED/REQUESTS: +- **@Lane B (real request this time, not an FYI):** please key `exposure` inside `applyDirect`'s + light branch — `if (d.exposure != null) key(d.ambientId, ['exposure', d.exposure]);` right after + the `bg` line. The main light payload already carries `exposure` at top level. That makes one + preset press one undo entry again; my second generic-shape event can then be deleted (it lands on + the same (t, 'exposure') key, so both together are harmless in the meantime). +- **@Lane B (optional):** `Timeline._mirror` is the natural home for "entity gone from stage → drop + the cameraCuts that point at it"; dock does it defensively today. +- **@Lane C (optional, unchanged):** `render.js` could call `stage.setFrameAspect(w/h)` before a + render; the guide now follows tlui's `select.rsize` on its own, so nothing is broken without it. +- Still owed by John: a viseme-bearing test character (carried from session 4). + +VERIFICATION (exact output): +``` +$ node scenegod/web/presets_test.mjs +presets_test: ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°, zoom 35→50mm at a fixed 1.61m) +$ node scenegod/web/stage_test.mjs +stage_test: ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m frame at 14m; shadow box +±6.95m solo / ±10.39m for a two-hander; 1600×600 gate bars 266.7px) +$ node scenegod/web/room3d_test.mjs +room3d_test: ALL PASS (26 CC bones paired against mixamo) +$ node scenegod/web/timeline_test.mjs # Lane B's, read-only +OK — timeline_test.mjs: all assertions passed +``` +`node --check` clean on stage.js, dock.js, presets.js, grammar.js, room3d.js and all three .mjs. + +NEXT / browser checks the orchestrator must do at :8020: +1. **Guide.** Drag the window narrow (taller than 16:9): NO black bars top/bottom any more, and the + gate edges go dashed with the "render is wider" note. Widen: pillarbox bars return. Change + tlui's render size — with a non-16:9 option the gate must change shape (all three are 16:9 + today, so this is only visible if Lane C adds one). +2. **Exposure.** `day` at t=0, `night` at t=5, scrub: the frame's level must ride with the presets. + Then `+ ambient` — the image must NOT jump; delete it — still no jump. +3. **Corner plate.** mode=corner under `noir`: the ground third and the wall must read as ONE + photograph, and a character standing on it must still cast a visible shadow onto it. +4. **Camera delete.** `+ camera (from view)` → gizmo-move it (no key) → 🗑: the dialog must mention + "1 camera cut", and after confirming, Save must succeed (this is the 422 that was reproducible). + The PiP must close instead of showing a duplicate of the director view. +5. **vid start.** Video plate, `vid start = 2`, scrub to t=3 → the 1.0s frame, and the rendered mp4 + must match at t=3. +6. **Key light aim.** Add a key light, translate it far along X: the character keeps his shadow + (the light still aims at the stage), and rotating a key light now does nothing — expected. + +## 2026-07-28 session 9 — round-2 review: 2 majors (both UNDO regressions of my own session-8 fixes) + 6 minors +Every defect was REPRODUCED first against the shipped files with the reviewer's own harness +(`scratchpad/laneA6/harness.mjs`, copied from `scratchpad/rev/`) — real stage.js/dock.js/presets.js +with three/room3d imports stripped, plus Lane B's REAL `timeline.js`. Then every fix was broken +again in a scratch COPY (`scratchpad/laneA6/discriminate.sh`, 10 mutations) to prove the new +assertions discriminate: 10/10 go RED on the mutation and GREEN on restore. + +### MAJOR 1 — one Ctrl+Z after deleting a camera resurrected the dangling cut +REPRO (`laneA6/r5b_cut.mjs`): `+ camera (from view)` → `_mirror` auto-cuts at 0 → gizmo-move → 🗑 → +`after delete: cuts=[] dangling=0`, then ONE `tl.undo()` → `cuts=[{"t":0,"camera":"e1"}] dangling=1`, +and the real validator (`.venv/bin/python -c "from scenegod.server import validate_scene"`) said +`["cameraCut references non-camera entity 'e1'"]`. +ROOT CAUSE: a delete spans two subsystems and only one of them has an undo. `Stage.removeEntity` +pushes nothing; `Timeline._mirror` splices the entity + its tracks out just as silently; but my +session-8 cleanup removed the cuts through `tl.group(() => tl.removeCut(c))`, which DOES push one. +Half-undoable = a state that is neither before nor after. +FIX (`dock.js`, exported `dropCuts(tl, cuts)`): still `tl.removeCut` for the model change (so Lane B +keeps owning `_activeCam` invalidation), then truncate `tl.undoStack` back to the mark taken before +the loop — the same field tlui marks/collapses around a drag. The cuts leave the way the camera did. +The dialog now says so, and lists only what exists: `Delete "camera"? 1 camera cut goes with it. +This cannot be undone.` (was `0 keyframes, 0 clip blocks, 1 camera cut go with it.`). +PROOF: post-undo scene through the real validator → `validate_scene(...) -> []`, six undos deep. +TESTS (stage_test): "no undo entry survives", "undoing everything on the stack can never resurrect a +cut whose camera is gone", plural/singular + "cannot be undone" wording, `dropCuts(null|{})` no-ops. + +### MAJOR 2 — one Ctrl+Z after a light preset stripped ONLY the exposure key +REPRO (`laneA6/r1b_undo.mjs`): `day`@0 + `night`@5 → undoStack 4 (2/press); one undo left +`color@5,intensity@5,bg@5` with no `exposure@5` — night's colours running at day's ACES level. +ROOT CAUSE: one `scenegod:direct` event gets one `applyDirect` → one `group()` → one undo entry, and +a preset has to go out as TWO events. +WHY THE BRIEF'S FIX ("fold exposure into the single payload, delete the second event") IS NOT +AVAILABLE YET, measured not assumed: `grep -c exposure timeline.js tlui.js` → **0 and 0**. The light +branch keys sun colour/intensity/transform + ambient colour/intensity/bg and has no `exposure` line, +and the two branches are `if (d.sunId != null && d.sun) … else {generic}` — one event, one branch, so +a single event can carry EITHER the sun transform key OR a third ambient param, never both. The +payload already carries `exposure` at top level; nothing reads it. Counterfactual, run end-to-end +against the real Timeline (`laneA6/cf.mjs`): +``` +SHIPPED (2 events, joined) | undo entries/press: 1 | exposure keys: 2 | renderer at t=0 after scrub back: 1 +2nd event DELETED | undo entries/press: 1 | exposure keys: 0 | renderer at t=0 after scrub back: 1.6 +``` +i.e. deleting the event alone re-opens round-1 major #1 (the day stretch renders at night's 1.6). +FIX (`presets.js` `_undoMark`/`_undoJoin` around the light branch): `collapseUndo` is Lane B's own +documented tool for "edits that span EVENTS rather than a function call" — it is how tlui turns 50 +mousemoves into one entry. Mark the stack before the first event, collapse after the last. One press += one entry; one Ctrl+Z takes the whole look (sun keys included) back. +TESTS (presets_test, real Timeline): `undoStack.length` grows by exactly 1 per press; the press keys +`bg,color,exposure,intensity` on the ambient AND `color,intensity` on the sun at the playhead; ONE +undo removes ALL of them (including the half that arrived on the other event) and restores the stack +depth; the earlier presses still agree with each other. + +### MINORS +1. **Thirds/action-safe were drawn at VIEWPORT fractions in the overscan branch.** `guideBars` now + also returns `side` — the SIGNED inset of the encoded frame's edges — and `_layoutGuide` places + the gate at it, so on a 900×700 window at 16:9 the gate hangs off both sides at −172px and its + 33.333% mark lands at x=242.6 (the real left third) instead of 300. `.frameguide{overflow:hidden}` + clips it; the dashed "⟷ the render is wider…" disclosure moved from `.fggate` to `.frameguide` + because the gate's own edges are now off-screen. Asserted twice: the pure geometry AND + `_layoutGuide` driving a fake DOM (`-172px` / `267px`). +2. **`setTimeout(readSize, 0)` lost the race** with two awaited dynamic imports. Deleted: + `mountDirectPanel` returns `syncFrameAspect`, and index.html calls it right after `new TimelineUI` + — the first moment `select.rsize` exists. Asserted: no timer in the code, and the call site is + AFTER `new TimelineUI(` in index.html. +3. **Light rotation was a silent no-op** (target nailed to the origin). Made honest rather than + re-broken: the inspector shows `pos` + "aims at the stage centre — MOVE it to change the light + angle (rotating does nothing)" (ambient: "no position, no angle"), `e`/`r` refuse to switch the + gizmo on a light, and selecting one resets it to translate. Plus the degenerate case the review + named: a light dragged ONTO (0,0,0) had a zero-length light→target vector — `_aimLight` aims it + straight down instead. +4. **Shadow catcher kept three's default `fog:true`** over a fog-free plate. `fog:false`, asserted. +5. **The ambient `exposure` field is inert once keyed** — now it says so. `keyedParam(tl,id,key)` + (exported from dock.js) + a message through **tlui's** toast channel (`window.sgToast`), the same + banner that already carries "… is keyed — press ⏺ key…" for a gizmo drag, so if Lane B lands the + same message inside `syncFromStage` (their timeline_test is currently red on exactly that) the two + writes overwrite instead of stacking two banners. +6. **Stale comment** at `_shadowDirty = true` → "consumed once per DRAWN frame in `_render`". + The "1 camera cut go" grammar nit went with MAJOR 1's dialog rewrite. + +DECISIONS: +- Delete stays NOT undoable, in both halves. Restoring an entity would mean re-loading its asset and + re-inserting Lane B's tracks; the honest reading is a one-way door, and the dialog now states it. + Truncating `undoStack` (rather than never calling `removeCut`) keeps the model change inside Lane + B's method, so `_activeCam` invalidation stays theirs. +- I did NOT delete the second exposure event: measured, it is still the only thing keying `exposure` + (counterfactual above). The undo cost it used to carry is gone, which was the actual defect. The + comment marks the exact one-line deletion for the day Lane B keys `d.exposure`, after which + `_undoJoin` self-neutralises (`collapseUndo` is a no-op for a single entry). +- The gate rectangle IS the encoded frame, even off-screen. Every interior mark is a percentage of + it, so this is one rule instead of a second set of overscan-aware coordinates for thirds and safe. + +BLOCKED/REQUESTS: +- **@Lane B (unchanged, now measured):** `if (d.exposure != null) key(d.ambientId, ['exposure', + d.exposure]);` after the `bg` line in `applyDirect`'s light branch. `grep -c exposure timeline.js` + = 0 today. When it lands, delete the second `_fire` + `_undoMark`/`_undoJoin` in presets.js. +- **@Lane B (FYI):** external harnesses that cherry-pick `Stage.prototype` methods need + `_aimLight` added alongside `_buildLight` now (mine and stage_test's are updated). +- Still owed by John: a viseme-bearing test character (carried from session 4). + +VERIFICATION (exact output): +``` +$ node scenegod/web/presets_test.mjs +presets_test: ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°, zoom 35→50mm at a fixed 1.61m) +$ node scenegod/web/stage_test.mjs +stage_test: ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m frame at 14m; shadow box +±6.95m solo / ±10.39m for a two-hander; 1600×600 gate bars 266.7px) +$ node scenegod/web/room3d_test.mjs +room3d_test: ALL PASS (26 CC bones paired against mixamo) +``` +`node --check` clean on stage.js, dock.js, presets.js, grammar.js, room3d.js and all three .mjs. +Lane B's `timeline_test.mjs` is RED at the moment on THEIR own new assertion ("an edit to a KEYED +param is no longer discarded in silence" — timeline.js has no such message yet); nothing in it +touches Lane A files. + +BROWSER CHECKS for the orchestrator at :8020: +1. **Camera delete + undo.** `+ camera (from view)` → 🗑 → dialog reads "1 camera cut goes with it. + This cannot be undone." → Ctrl+Z: NOTHING comes back, and Save still succeeds. +2. **Light preset undo.** `day` at 0, `night` at 5, ONE Ctrl+Z at the playhead: night's colours AND + its level go together (the frame must not sit at day's exposure with night's colours). +3. **Guide on a tall window.** Drag the window narrow: dashed edges + the "⟷ the render is wider…" + note, and the thirds must sit INSIDE the visible area at ~27%/73%, not at 33%/67%. +4. **Light inspector.** Select a key light: pos + the "aims at the stage centre" line, no rot/scale + fields, and `e` leaves the gizmo in translate. +5. **Keyed exposure.** After any preset, type into the ambient's `exposure`: the tlui toast must say + "exposure is keyed — … Press ⏺ key…" (and only one banner, if Lane B lands theirs too). diff --git a/logs/laneB.md b/logs/laneB.md index 953cfb8..d8c9984 100644 --- a/logs/laneB.md +++ b/logs/laneB.md @@ -685,3 +685,374 @@ NEXT: nothing required. Backlog, in order: (1) "nudge to grid" for EXISTING overlay on the transform lane) is the obvious next UX step; (3) box-select still only grabs keys, not clip/audio blocks or cuts; (4) per-drag undo coalesce; (5) film-level `audio[]` has no UI. + +## 2026-07-28 session 10 (make the app usable: render button, save truth, cut model, outliner) +DONE: all five items. No git (standing instruction), no browser (instructed), :8020 + untouched — read-only curl confirms it already serves the edits (`/web/tlui.js` + carries `renderPlan`/`syncFromStage`). Files: `timeline.js`, `tlui.js`, + `stagestub.js`, `timeline_test.mjs`, this log. + +**1 — ⏺ Render in the scene bar** (`tlui.js:214-220` bar markup, `render()` at + `tlui.js:1414`, pure `renderPlan(tl, sizeStr)` at `tlui.js:113`). + `finalRender()` had ZERO callers; the only render control was inside the collapsed + 🎬 panel (needs Save → New… sequence → + shot, renders SILENT, clobbers the open + scene). Now: size select (verbatim copy of the FILM head's three options) + `⏺ Render` + → pause + remember `tl.time` → lazy `import('./render.js')` (same shape as `_filmMod()`) + → `finalRender(stage, tl, {...renderPlan(size), onProgress})`. Button reads + `rendering 41/600` while it runs, both controls disabled, `_rendering` refuses a second + click; success puts a persistent relative `` in `.rstat` + AND toasts; failure toasts `_errText(e)`; `finally` re-enables and `seek(saved)`. + Chrome hiding untouched — Lane C moved it INTO `captureFrames` (`render.js:76`, + `stage.pause?.()`) mid-session, so it now happens for free on this path too. + `renderPlan` throws `too long: N frames (max 20000)` (server.py:439's cap) and falls + back to 1920×1080 on an unparseable size. + +**2 — save the stage you actually built** (`Timeline.syncFromStage()` `timeline.js:175`, + called at the top of `tlui.save()` `tlui.js:1456`). `_mirror` copies transform+params + ONCE and early-returns forever (`timeline.js:145`), so a prop dragged into place, a key + light dialled to 0.8 and a backdrop's fitDist all reverted to their drop-time values on + save/reload; `Stage.captureState()` (stage.js:682), which exists for exactly this, had + zero callers. Merge takes ONLY `transform` and `params` (structuredClone), never adds, + never removes, never touches `tracks` — the stub's captureState clones whole entities + INCLUDING tracks, so a naive merge would have overwritten live keys with a stale copy. + `params.fit === 'frustum'` backdrops keep their authored transform (that wrapper is the + lens's, stage.js:485) and take their params. + +**3 — save/load stop lying** (`tlui.js:1450-1560`, pure `uploadEntities`/`saveMessage` + at `tlui.js:133`/`145`). A 422 from `validate_scene` used to be indistinguishable from + success (silent localStorage write), and since `load()` prefers the server copy the next + Load restored the last good version and ate everything since. Now the response is + awaited: `!res.ok` → console.error + toast `NOT saved — ` and NO + localStorage; only a thrown fetch falls back, toasting `server unreachable — kept a + local copy in this browser only`; success toasts `saved "name" · N entities`. Upload + entities are collected and confirmed before the POST (PLAN.md:72-73, never built). + Load is now a `tl-menu` popover of `GET scenes` (name · duration · mtime), gated on + `hasContent()` → confirm like the other two clobbering actions, with `applyScene` + wrapped in try/catch so one missing clip can't kill `_decodeAll()` and leave the audio + row dead. + +**4 — cameras go live at a cut** (`timeline.js:156-163`, `addCut` `:527`, `moveCut` `:549`, + `_evalCameraCuts` `:471`, `tlui.js` `_drawCuts`/`_ctx`/`_move`). + (a) `_mirror` gives the FIRST camera in a cutless scene a cut at 0 + a toast — dock + `_addRig` sets it active for the PiP and the very next `evaluate` used to snap the view + back to the orbit cam. Guarded by `tl._applying` (set around `stage.applyState` in + `applyScene`) so scene loads don't toast. + (b) `addCut` REPLACES any cut within half a frame (autoCutPlan's own rule), in one undo + entry — three "+ angle"s at the playhead used to stack three overprinted cuts, two of + them dead. + (c) the Cameras lane prints the camera's LABEL (was the raw id, so it read `e3 e5 e7` + next to a names column saying `cam2 / cam3`). + (d) right-click delete routes through `tl.removeCut` and the drag through the new + `tl.moveCut` — both were raw mutations, so Ctrl+Z after either reverted an unrelated + earlier keyframe. + (e) `_evalCameraCuts` compares against `stage.activeCamera()` (added to stagestub) so + nothing can desync the cached value permanently. + +**5 — the names column is the outliner** (`tlui.js` `_syncRows`, `_stageChanged`, + `_markSel`, CSS block; pure `rowFlags(entity)` at `tlui.js:157`). + Every row with an entity gets `.pick`, a title, `onclick → stage.select(id)` (guarded by + `getEntity`) and a `.sel` left-border highlight driven by `stage.onSelect`. This is the + ONLY way to reach an ambient light: its wrapper holds a HemisphereLight with no + geometry, so `Stage._pick` can never hit it, and every DIRECT lighting preset creates + one (intensity/colour/bg/fog were devtools-only). Rows whose entity has ≥1 transform key + carry a `⏺` glyph, and `stage.onChange` (gizmo mouseUp, stage.js:116 — never from + setTransform, so no feedback loop) toasts that the pose will revert on the next scrub. + No auto-keying. + +TEST: `node scenegod/web/timeline_test.mjs` → **OK — all assertions passed**; + `node --check` clean on timeline.js, tlui.js, stagestub.js, timeline_test.mjs; Lane A's + `presets_test.mjs` / `stage_test.mjs` / `room3d_test.mjs` all still ALL PASS. + New blocks: renderPlan (1280x720 → 1280/720, garbage → 1080p, `1920×1080` glyph form, + 20s@30 → 600, audio passed through incl. gain, empty scene renders, 700s@30 throws with + 'max 20000' AND '21000 frames'); syncFromStage (gizmo pos `[-3,0,1]` and fov 28 land in + `toJSON()`, e1 keeps BOTH original keys and its clip block, the frustum plate takes + `fitDist: 20` but keeps its transform byte-identical, an entity unknown to the model is + skipped not adopted, synced scene still round-trips); uploadEntities/saveMessage (all + four message cases); the cut model (0.01s replace at 30fps → length stays 1 / camera c2, + 1.0s appends, undo restores the replaced cut, removeCut+moveCut undo, and a seek + re-asserting the cut camera after an out-of-band `setActiveCamera`); rowFlags. + Three EXISTING blocks were adapted, not deleted, because behaviour changed on purpose: + the DIRECT block now asserts the auto first cut (`timeline_test.mjs:237-244`) and that a + second camera adds none; the MOVE block removes that cut explicitly to test moves in + isolation; the 3-camera auto-cut case asserts + removes it. Each adaptation FAILED + first on the new code and was checked, not silenced. +DECISIONS: + - **`_evalCameraCuts` does NOT use `?? this._activeCam`** as the brief wrote it: a stage + sitting on the director cam reports `null`, and `null ?? staleId` would fall back to + the stale id — reintroducing the exact desync the change fixes. It prefers the stage's + answer whenever the accessor exists, keeping `_activeCam` only as the fallback for a + Stage that predates it. + - **Known consequence of that**: adding a SECOND camera (dock sets it active for the + PiP) now loses the preview at the next scrub, because the edit still cuts to the + first. That is the intended "the edit wins" reading, and the M11 gesture + (double-click the Cameras row = cut to the camera you're looking through) is the + documented way to make it stick. Flagging it because it is a visible behaviour change. + - **`_applying` flag** rather than the brief's `stage.getEntity()` guard: during + `applyScene` the entity IS on the stage, so that guard can't tell a load from a live + add. The flag also silences the "is keyed" toast during a load. film.js's per-shot + `applyScene` gets it for free. + - **Toast wording**: `press ⏺ key in the inspector, or double-click its lane` — the + brief's bare "⏺" now collides with the new `⏺ Render` button in the scene bar. + - **stagestub honesty (two fixes)**: `select()` now hands listeners the ENTITY, not the + id (real Stage: stage.js:458 — an outliner reading `en.id` would have passed here and + broken live), and `activeCamera()` was added to match stage.js:587. + - `saveMessage` treats **absent `status`** as the network case — that is the only state + where no response exists, so the local-copy claim can't be made by mistake. + - `renderPlan` returns `total` (unused by finalRender, which recomputes it) so the + refusal and the count on screen come from one place. +BLOCKED/REQUESTS: + - **@orchestrator — browser verification owed for item 5.** I am instructed not to use + browser tools this session, so the click/highlight/toast behaviour of the outliner is + headless-verified only (`rowFlags` + code review). Please check live: click a names + row → the stage selects it and the row grows a blue left border; add an ambient light + via a DIRECT lighting preset and confirm its row is now reachable; drag a KEYED entity + with W and confirm the toast; and that `⏺ Render` produces an mp4 with audio. + - **@orchestrator**: PLAN §4.1 still doesn't list `scene.beatGrid` or audio + `{in?, out?}` (carried from sessions 7-9). + - **@Lane C (nit)**: `GET scenes` gives `{name, mtime, duration}` — the Load picker shows + all three. Nothing needed unless you want a scene's entity count in the list. +NEXT: nothing required. Backlog unchanged and still in order: (1) "nudge to grid" for + EXISTING keys/cuts; (2) a camera move is only editable as two keys — dragging the pair + together; (3) box-select still only grabs keys, not clip/audio blocks or cuts; (4) + per-drag undo coalesce (a mousemove drag still pushes one entry per frame — now true of + `moveCut` too); (5) film-level `audio[]` has no UI. + +## 2026-07-28 session 11 (adversarial review of session 10 — 4 majors + 4 minors) +DONE: every blocker/major from the reviewer's verdict, plus all four minors. Each was + REPRODUCED headlessly first (scratch node scripts, not in the repo), fixed at the root, + and given a regression test that was checked RED against the old behaviour and green + after. No git, no browser, :8020 untouched (read-only curl confirms it already serves + the edits — `/web/tlui.js` carries `renderRefusal`/`cutAction`). + +**BLOCKER — `syncFromStage()` rewrote the AUTHORED rest pose of keyed entities** + (`timeline.js`, the `syncFromStage()` body). Repro: e1 keyed [0,0,0]@0 → [5,0,0]@5, sun + intensity keyed 0→8; scrub to 5 + save → rest becomes [5,0,0]/8; scrub to 2.5 + save → + [2.5,0,0]/4. Root cause: `Stage.captureState()` returns `entityTransform(id)`, i.e. the + pose the TIMELINE just wrote through setTransform/setParam on the last evaluate — so the + merge copied the timeline's own output back over the source of truth, and PLAN §4.1's + "rest pose (used when no keys)" plus save idempotence both died. FIX: the sync is now per + CHANNEL — an entity with ≥1 transform key keeps its authored `transform`; a param NAME + that appears in `tracks.params` keeps its authored value while every other param on the + same entity still syncs. Unkeyed props/lights/plates are unchanged (that was the point of + session 10's item 2 and it still works). +**MAJOR — no way to cut to a second camera from the timeline** (`tlui.js` `_dbl` → + new `_cutPick`/`_cutTo`, pure `cutAction()`). Repro: add cam1 (auto cut at 0), add cam2 + (dock sets it active), `seek(1.0)` → `stage.activeCamera()` is back to cam1, so the M11 + gesture appended `{t:4, camera:'cam1'}` — a literal no-op cut, the artefact the M10 + amendment asked to be dropped. Root cause: the gesture read `stage.activeCamera()`, but + `_evalCameraCuts` *drives* that value at every seek, so it can only ever echo the edit. + FIX: the row ASKS. `_cutPick` opens a `tl-menu` of the scene's cameras (the one live at t + marked "(live here)"), one camera = no menu, none = a toast. `_cutTo` routes through pure + `cutAction(cuts, t, camId, fps)` → `add` | `replace` (addCut's half-frame rule) | `remove` + (a cut here that changes nothing is deleted rather than re-stacked) | `noop` (says so). + `_evalCameraCuts` itself is unchanged — "the edit wins" is still the model; there is now + an honest way to author the edit. The dock's ◉ preview is still transient by design. +**MAJOR — ⏺ Render and 🎬 Render film were not mutually exclusive** (`tlui.js` + `renderRefusal()`, `render()`, `_filmRender()`, `_filmBusySet()`, new `_busy()`/ + `_barBusySet()`). Repro (fake `this`, no DOM): with `_filmBusy` true, `render()` reached + the plan; with `_rendering` true, `_filmRender()` reached the save+render. Two capture + loops on one `timeline.step()`, and whichever finished first called `stage.resume()` and + un-hid the gizmo for the rest of the other one's frames (SYNC7, again). FIX: one gate both + entry points consult, plus the whole scene bar goes dead during EITHER render (Save, New…, + Load, ▶ and the size select), `_toggle`/`_down`/`_dbl`/`_ctx`/`_key` refuse while busy + (`?` and Escape still work), and `_rendering` is claimed BEFORE the first await so two fast + clicks can't both pass. +**MAJOR — the cut lane's camera label was invisible** (`tlui.js`: exported `LANE_BG`, + `CUT_COLOR`, `LABEL_COLOR`, used by `draw()` and `_drawCuts`). It was painted in `#161a20` + — the odd lane's own fill (1.00:1), `#13171d` on the even one (1.03:1). So session 10's + claim "the lane prints the camera's LABEL" was not observable. FIX: the label is the cut's + own amber `#e0af68`; the colours are exported constants so the fill and the text can't + drift apart again, and the test holds them to ≥3:1 WCAG contrast against BOTH lane fills. +**MINOR — moveCut had no half-frame rule and one undo entry per mousemove** + (`timeline.js` `settleCut()` + `collapseUndo()`, wired in `tlui.js` `_down`/`_up`). + Repro: 50 synthetic mousemoves left 50 undo entries and two cuts 4e-16s apart, one of + which `cutCameraAt` can never reach. FIX: `_down` marks the undo depth, `_up` settles the + dropped cut (absorbing anything inside half a frame, reported in a toast) and collapses + the whole drag to ONE entry — which also closes backlog item (4) for keys, blocks and + audio drags. `group()` now delegates to `collapseUndo`, one definition of the collapse. +**MINOR — save() reported a filename the server did not use** (`tlui.js` `save()`). + Repro: POST `scenes/My%20Scene` → server writes `my-scene.json`, answers `{"name": + "my-scene"}`, toast said `saved "My Scene"`. FIX: the stem from the response body is what + the toast reports, and it is written back into the name field + `scene.name`, so the bar + matches the Load picker and the next save writes the same file. +**MINOR — audio was validated only after every PNG had been uploaded** (`tlui.js` + pure `audioProblems()` + `_audioMissing()`, called by `render()`). FIX: everything + `resolve_audio` refuses without the filesystem (missing/non-string path, non-numeric + start/gain/in/out, negative start/in, out ≤ in) is refused client-side like the frame cap, + and a HEAD per distinct path against `assets/file?path=` catches a moved/deleted file + before frame 1. Fails OPEN on a network error — the server keeps the last word. +**MINOR — stale file:line pointers in comments**: every cross-lane citation in my files + now names a SYMBOL instead of a line (`stage.js setTransform`, `stage.js select()`, + `server.py render_frame`, `server.py validate_scene`, PLAN §4.1). Lines drift when another + lane edits; symbols don't. Same rule applied to this log entry. +TEST: `node scenegod/web/timeline_test.mjs` → **OK — all assertions passed** (exit 0); + `node --check` clean on timeline.js / tlui.js / stagestub.js / timeline_test.mjs; Lane A's + presets_test / stage_test / room3d_test and Lane C's scripts/test_render_client.mjs all + still pass. New/changed blocks: playhead-independent save (two saves at different times + byte-identical, keyed transform + keyed param preserved, unkeyed colour on the same light + still synced); the `syncFromStage` block was RE-POINTED at a new unkeyed `crate` prop — + the old assertion "the gizmo pose is what gets saved" was made on e1, which HAS two keys, + i.e. the suite encoded the bug as intended behaviour; renderRefusal + both wiring + directions through the prototype + `_filmBusySet` taking the bar out; audioProblems (8 + cases) + the two refusal wirings incl. the gate being released; cutAction (8 branches), + `_cutTo` end-to-end after a scrub, `_cutPick`'s one-camera and no-camera branches; the + contrast check; settleCut/collapseUndo + the `_up` drag it exists for. + DISCRIMINATION PROVEN — each fix was temporarily reverted and the suite went red on the + intended assertion: rest-pose sync → "a keyed entity keeps the AUTHORED rest pose"; + keyed-param sync → "two saves … byte-identical"; render gate → "⏺ Render never even plans + while a film render runs"; bar lockout → "a film render disables the whole scene bar"; + audio pre-flight → "the render must not start"; `_cutTo` guard → "asking for the camera + already live adds NO dead cut"; label colour → "must read against lane fill #13171d (got + 1.03:1)"; settleCut/collapseUndo → "head trim is a single undo entry" (an EXISTING + assertion, via group()); `_up` wiring → "mouseup settles the dropped cut"; save name → + "the toast names the file the server actually wrote". All restored green afterwards. +DECISIONS: + - **Per-channel, not per-entity, sync.** Skipping a whole entity that has any keys would + have thrown away the unkeyed inspector edits on the same object (a keyed light's colour, + a keyed camera's non-animated params). The keyed CHANNEL is the thing the timeline owns. + - **A keyed param with no authored rest value is DELETED from `params`, not frozen at the + live lerp.** Freezing would invent a rest value the user never typed; deleting keeps the + file honest and the key still drives it at evaluate time. + - **Ask, don't guess, for the cut camera.** Any "smart" reading of intent here is a guess + about which camera the director means; the menu is one extra click and can't be wrong. + The `remove` branch exists because a cut that changes nothing is not an edit. + - **The bar dies during a render, the canvas gate is separate.** Disabling DOM controls + can't stop Space or a canvas drag, so `_busy()` guards the handlers as well. + - **The audio existence check fails OPEN.** A wobbling network must never block a render + that would have worked; the server still validates. + - **Colours are exported constants.** The bug was a literal duplicated between the fill and + the text; the test asserts a contrast ratio, not a hex, so a future palette change is + free but an invisible one is not. +BLOCKED/REQUESTS: + - **@orchestrator — browser verification still owed** (same as session 10, plus this + round): the Cameras-row picker (double-click with 2+ cameras → menu → cut lands and the + label is now READABLE amber), ⏺ Render and 🎬 Render film refusing each other with the + scene bar greyed out, and the outliner items from session 10. All headless here. + - **@orchestrator** — logs/laneB.md session 10 claims "double-click the Cameras row is the + documented way to make [a second camera] stick". That was wrong after any scrub; this + session replaces it with the picker. Flagging it since the log is append-only. + - **@orchestrator** — PLAN §4.1 still doesn't list `scene.beatGrid` or audio `{in?, out?}` + (carried from sessions 7-10). +NEXT: nothing required. Backlog: (1) "nudge to grid" for EXISTING keys/cuts; (2) a camera + move is only editable as two keys — dragging the pair together; (3) box-select still only + grabs keys, not clip/audio blocks or cuts; (4) film-level `audio[]` has no UI; (5) the + `_cutPick` menu is the only untested-by-node path in the new work (needs DOM) — worth a + look at SYNC. + +## 2026-07-28 session 12 (round-2 adversarial review — 2 majors + 6 minors) +DONE: both remaining MAJORs and ALL SIX remaining minors. Every one was REPRODUCED + first — the two majors against a real uvicorn I started on port **8097** (my own, + killed afterwards; :8020 never touched, read-only curl confirms it already serves + these edits) and headless node repros in scratch, none in the repo. No git, no + browser. + +**MAJOR — the audio existence pre-flight could never fire** (`tlui.js` + `_audioMissing`). REPRO, live, against this repo's server.py: `HEAD + /assets/file?path=audio/test/vo.wav` → **405, `allow: GET`**; `HEAD …nope.wav` → + **405**; GET → 404; the previous implementation run verbatim against that server + returned `[]` for a file that does not exist. ROOT CAUSE: FastAPI's `APIRoute` + registers ONLY the methods you decorate — unlike starlette's own `Route` it does + not add HEAD to a GET route — so `@app.get("/assets/file")` answers every HEAD + with 405 and `r.status === 404` was unreachable. The session-11 claim "a HEAD per + distinct path catches a moved/deleted file" was false in production. FIX: a real + GET with `Range: bytes=0-0` — the method the route answers — and the body is + cancelled instead of streamed, so proving a 40MB stem exists costs one byte + (measured: 206, content-length 1). Still fails OPEN. Same function against the + live server now returns `['audio/nope.wav']` and passes the file that is there. +**MAJOR — the render mutex still raced** (`tlui.js` `_filmRender`, `render`). + REPRO (scratchpad, both real prototypes on a fake `this`, `_filmSave` held open): + 🎬 ran its gate, awaited the save, and during that round-trip `_rendering`/ + `_filmBusy` were both false and the bar was live — a ⏺ click passed + `renderRefusal`, called `tl.pause()` and started capturing; the film then set its + flag and started a second capture on the same clock. ROOT CAUSE: the flag was + claimed two awaits late. FIX: `_filmBusySet(true)` is now claimed SYNCHRONOUSLY + before the first await and released in a `finally` that covers every exit + (including "the save failed", which used to `return` with the gate held in the + new shape); `_filmRender`'s gate now reads BOTH flags through `renderRefusal` and + says which render is in the way instead of returning silently; `render()`'s + release moved out of a hand-written early-return into the same `finally`, and its + two synchronous reads (`tl.time`, the button label) now happen BEFORE the claim so + a throw there cannot strand a permanently-"rendering" app. +**MINOR — the saved FILE still carried the un-slugged name** (`tlui.js` `save`). + REPRO live: POST `scenes/My%20Scene` → `{"name":"my-scene"}` and `my-scene.json` + containing `"name": "My Scene"` (server.py `scene_save` stores the body verbatim), + so every Load put the mismatch back. FIX: when the server's stem differs from what + we sent, the stem goes into the model/field AND one corrective re-POST rewrites the + file. Idempotent (`slugify(slug) === slug`) so it converges in exactly one extra + request, and only when the name needed slugging. Verified live: bar, picker and + stored `name` all read `round-two-scene`. +**MINOR — a keyed param with no authored rest value was DELETED, and an inspector + edit to one vanished in silence** (`timeline.js` `syncFromStage`, `_evalParams`). + FIX (reverses last session's DECISION, which was wrong): the rest value falls back + to the FIRST KEY's value — that is exactly what `evaluate` holds for t before it, + so it is authored, playhead-independent and invents nothing, and a keyed camera no + longer saves with no `fov` in `params` at all. For the silence: `_evalParams` now + records what the timeline itself last wrote per `id::key` (`_drove`), so + syncFromStage can tell its own lerp (say nothing) from a human's inspector edit + (say that it will not stick, and where to key it). dock.js `_param` calls + syncFromStage directly, so this is the exact line where the edit dies. +**MINOR — `cutAction` could MANUFACTURE the dead cut it exists to prevent** + (`tlui.js` `cutAction`, `_cutTo`). REPRO: cuts [{0,cam1},{4,cam2}], cut at 0 to + cam2 → 'replace' → [{0,cam2},{4,cam2}]; it only ever looked BEFORE t. FIX: after + every branch the effective camera from t onward is camId, so the next cut is dead + iff it names camId — reported as `dead`, but only when it was a real angle change + BEFORE the edit (`wasEff`), so a pre-existing dead cut is left alone rather than + silently deleted. `_cutTo` drops it inside ONE `group()` with the cut itself and + names it in the toast. Same rule now covers add/replace/remove. +**MINOR — the camera picker was invisible to Escape and leaked its listener** + (`tlui.js` `_cutPick`, new `_pickClose`, `_key`). The menu was a local `const`; + `_cutMenuClose` closes `this._cutMenu`, the ✂ AUTO-CUT panel — a different menu. + FIX: tracked on `this`, one close path that also removes the document mousedown + listener, Escape wired to it, and opening a second picker closes the first. +**MINOR — two claimed wirings were asserted nowhere** (`_undoMark` in `_down`, the + `_busy()` gates). FIX: the drag test now goes through the REAL `_down` instead of + hand-setting `_undoMark`, so deleting that line takes the "one undo entry per drag" + assertion down with it; `_down`/`_dbl`/`_ctx` are called with a `_local` that throws. +**MINOR — director/clip-drop events mutated the scene mid-capture** + (`timeline.js` new `_locked`, `tlui.js` `_barBusySet`). `_barBusySet` can only + disable controls inside `$bar`; Lane A's DIRECT panel and the dock dispatch window + events straight at timeline.js. FIX: `_barBusySet` — the one choke point BOTH + render paths go through — sets `tl.locked`, and `scenegod:direct`, + `scenegod:clipdrop` and `scenegod:capturekey` refuse (out loud) while it is set. +TEST: `node scenegod/web/timeline_test.mjs` → **OK — all assertions passed** (exit 0); + `node --check` clean on timeline.js / tlui.js / stagestub.js / timeline_test.mjs; + Lane A's presets/room3d/stage tests and Lane C's test_render_client.mjs still pass. + DISCRIMINATION PROVEN, mechanically: `scratchpad/rr2/discriminate.py` copies web/ + + templates/ + scenes/ to a scratch tree, breaks ONE fix at a time (17 of them) and + runs the suite. Baseline GREEN → all 17 RED on the intended assertion → restored + GREEN. e.g. HEAD restored → "a moved/deleted audio file is actually DETECTED"; + claim moved back after the save → "the film claims the gate BEFORE its first + await"; `dead` forced null → "the 4s cut is now dead and is named"; `_undoMark` + line deleted → "mousedown marks the undo depth"; `delete next[k]` restored → "an + unauthored keyed param rests at its FIRST key". +DECISIONS: + - **A one-byte ranged GET, not "drop the pre-flight".** The check is worth keeping + (a moved VO otherwise costs a whole render), it just has to speak a method the + route answers. Asking for `bytes=0-0` and cancelling the body keeps it as cheap + as the HEAD was meant to be. + - **Claim synchronously, release in `finally`.** Every "reset the flag on this + early-return" line is a future leak; there is now exactly one release per render + path and no exit that skips it. + - **The first key is the rest value.** Last session I argued deletion "keeps the + file honest" — it doesn't, it drops the only fallback the entity has if the keys + are later removed. The first key is authored data and does not move with the + playhead, which was the actual property worth protecting. + - **Only drop a dead cut MY edit killed.** Cleaning up cuts that were already + redundant would be deleting something the user never asked me to touch. + - **`locked` lives on the model.** DOM disabling stops at `$bar`; anything that can + mutate the scene from outside it has to be able to ask. +BLOCKED/REQUESTS: + - **@orchestrator — browser verification still owed** (carried from sessions 10-11, + plus this round): the Cameras-row picker closing on Escape and dropping a dead + following cut; ⏺ vs 🎬 refusing each other with the bar greyed; a DIRECT preset + clicked mid-render toasting instead of keying; a render refused up front when an + audio file has been moved. + - **@Lane C (nit, not a blocker)**: `@app.get("/assets/file")` 405s on HEAD. My + check no longer needs it, but a plain `@app.head` (or `methods=["GET","HEAD"]`) + would be the conventional shape for a static-file route if you ever want it. + - **@orchestrator** — PLAN §4.1 still doesn't list `scene.beatGrid` or audio + `{in?, out?}` (carried from sessions 7-11). +NEXT: nothing required. Backlog: (1) "nudge to grid" for EXISTING keys/cuts; (2) a + camera move is only editable as two keys — dragging the pair together; (3) + box-select still only grabs keys, not clip/audio blocks or cuts; (4) film-level + `audio[]` has no UI. diff --git a/logs/laneC.md b/logs/laneC.md index c93981a..0f15f5f 100644 --- a/logs/laneC.md +++ b/logs/laneC.md @@ -528,3 +528,215 @@ BLOCKED/REQUESTS: none. Everything else from session 7 (trim work, speed, cachin 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,97` — `captureFrames` 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-52` — `withRenderSize` 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,`, 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. diff --git a/scenegod-8020.log b/scenegod-8020.log index a0dcddb..b28dcf4 100644 --- a/scenegod-8020.log +++ b/scenegod-8020.log @@ -2225,3 +2225,52 @@ INFO: 127.0.0.1:61364 - "GET /assets/file?path=characters%2Ftest%2Fman.fbx H INFO: 127.0.0.1:61364 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK INFO: 127.0.0.1:61364 - "GET /assets/file?path=animations%2Ftest%2Fcarrying.fbx HTTP/1.1" 200 OK INFO: 127.0.0.1:61364 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content +INFO: 127.0.0.1:65390 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:65391 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [77004] +INFO: Started server process [86259] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit) +INFO: 127.0.0.1:52809 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:57456 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:57457 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:57574 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:60682 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:60683 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61588 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:61782 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:65341 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:65342 - "GET /web/timeline.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:49922 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:50070 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:50071 - "GET /web/timeline.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:50333 - "GET / HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [86259] +INFO: Started server process [5165] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit) +INFO: 127.0.0.1:51793 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:51821 - "GET /?cb=final HTTP/1.1" 200 OK +INFO: 127.0.0.1:51821 - "GET /web/style.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:51821 - "GET /web/stage.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51823 - "GET /web/dock.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51823 - "GET /web/room3d.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51821 - "GET /web/presets.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51823 - "GET /assets/tree HTTP/1.1" 200 OK +INFO: 127.0.0.1:51821 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:51821 - "GET /web/grammar.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51821 - "GET /director HTTP/1.1" 405 Method Not Allowed +INFO: 127.0.0.1:51823 - "GET /web/timeline.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51823 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:51851 - "GET /scenes/music-video HTTP/1.1" 200 OK +INFO: 127.0.0.1:51851 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 200 OK +INFO: 127.0.0.1:51851 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK +INFO: 127.0.0.1:51851 - "POST /scenes/final-check HTTP/1.1" 200 OK diff --git a/scenegod/server.py b/scenegod/server.py index fac6977..f02a0c0 100644 --- a/scenegod/server.py +++ b/scenegod/server.py @@ -93,11 +93,22 @@ app = FastAPI(title="SCENEGOD") # ---- path guard (ship-check rule: every path-taking endpoint uses this) ---- def safe_under(root: Path, relpath: str) -> Path: - """Resolve relpath under root; reject traversal, absolute, symlink escape.""" - full = (root / relpath).resolve() - if full != root and root not in full.parents: + """relpath resolved under root; rejects absolute paths and every `..` escape. + + LEXICAL on purpose — do NOT "fix" this back to `.resolve()`. resolve() + follows symlinks, so a bank the operator deliberately symlinked into the + asset root (`ln -s ~/MESHGOD/assets3d/gallery assets/props`, the documented + way to get characters/clips/props in — PLAN §4.3) resolved OUTSIDE the root + and every single fetch 400'd, while /assets/tree happily listed the files. + os.path.normpath collapses `a/../../x` to `../x`, so traversal is caught + before touching the disk; user-supplied input still cannot escape. The only + thing this permits is a symlink an operator placed inside a curated root. + (A symlink LOOP under assets would hang scan_assets' os.walk — accepted: the + asset root is operator-curated, not user-writable.)""" + rel = os.path.normpath(str(relpath).replace("\\", "/")) + if os.path.isabs(rel) or rel == ".." or rel.startswith("../"): raise HTTPException(400, "bad path") - return full + return root / rel # unresolved: a symlinked bank inside root must stay usable def _num(v, default=None): @@ -162,7 +173,9 @@ def scan_assets() -> dict: if not base.is_dir(): continue groups: dict[str, dict] = {} - for dirpath, dirs, files in os.walk(base): + # followlinks: a bank symlinked one level deeper (assets/props/gallery -> + # ~/MESHGOD/assets3d/gallery) must be scanned, not silently empty. + for dirpath, dirs, files in os.walk(base, followlinks=True): dirs[:] = [d for d in dirs if not d.startswith(".")] for f in sorted(files): stem, dot, ext = f.rpartition(".") @@ -392,14 +405,112 @@ def audio_filters(audio: list[dict], first_input: int) -> list[str]: return parts -def _encode(rid: str, fps: int, audio: list[dict]): - """audio = [{file: abs path, start: sec, gain: float, in?, out?}] (resolved).""" +def video_args(crf: int = 18) -> list[str]: + """OUTPUT-only x264 options, shared by the shot encode and the film concat. + + The colour tags are the point: an untagged yuv420p mp4 makes QuickTime, + Safari, Finder preview and most NLEs GUESS the primaries/transfer, and the + guess shifts hue and contrast away from what the WebGL canvas drew — which + reads as "the render came out wrong". three.js outputs sRGB, i.e. bt709. + +faststart because these are played over http; -preset slow costs a few + seconds on a 300-frame render at the same crf.""" + return ["-c:v", "libx264", "-preset", "slow", "-profile:v", "high", "-level", "4.2", + "-pix_fmt", "yuv420p", "-crf", str(crf), + "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", + "-color_range", "tv", "-movflags", "+faststart"] + + +# MEASURED on ffmpeg 8.1.2: the -color_primaries/-color_trc OUTPUT options above +# are silently ignored (only -colorspace lands) because the frames coming out of +# the png decoder + auto-scaler carry "unspecified" and frame properties win. +# Tagging the FRAMES is what actually writes the VUI, so every encode ends in +# this filter — verified with ffprobe in scripts/test_server.py, not assumed. +SETPARAMS = "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv" + +# ...but a TAG IS NOT A CONVERSION, and setparams actively breaks the one ffmpeg +# would otherwise negotiate. MEASURED (ffmpeg 8.1.2, pure green PNG in, first +# luma sample out): `scale=w:h:flags=lanczos,setparams=...` stores Y=145 — that +# is bt601's green (bt709's is 173) — and the file then decodes through its own +# bt709 tag as (0,216,0). Realistic case: neon (230,40,120) came back (244,61,120). +# Cause: setparams 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 from `-colorspace` and gets it right. +# So the conversion must be stated on the converting filter itself. `out_color_matrix` +# on scale = Y 173, round-trip (0,255,1), (230,38,119). Never write one without +# the other. (Range stays swscale's default limited, which is what `range=tv` says.) +CONVERT = "out_color_matrix=bt709" + + +def vf_args(meta: dict) -> list[str]: + """The output video filter chain: downscale to the render's DECLARED size + (render/begin), convert RGB->YUV with the bt709 matrix, then tag bt709. + + render.js supersamples: it renders the viewport at 2x the deliverable and + lets ffmpeg's lanczos do the resolve, which is the only anti-aliasing that + touches shading and texture aliasing (MSAA only fixes geometry edges) — the + frame-to-frame crawl on fences/foliage/hair is the loudest amateur tell in + motion. Near-noop when the frames already match. When begin declared no size + the scale keeps iw:ih (it is there for the matrix, not the resize). + + It does NOT police the frame size — a capture SMALLER than the declared size + would be silently upscaled here, so _encode ffprobes the first frame and + errors out before we get this far.""" + try: + w, h = int(meta.get("width", 0)), int(meta.get("height", 0)) + except (TypeError, ValueError): + w = h = 0 + size = f"{w}:{h}:flags=lanczos:" if w >= 16 and h >= 16 else "" + return ["-vf", f"scale={size}{CONVERT},{SETPARAMS}"] + + +def _png_size(path: Path) -> tuple[int, int]: + """(w, h) of an image/video file, or (0, 0) if ffprobe can't say.""" + try: + out = subprocess.check_output( + ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", + "stream=width,height", "-of", "csv=p=0:s=x", str(path)], + text=True, stderr=subprocess.DEVNULL).strip() + w, h = out.split("x")[:2] + return int(w), int(h) + except (OSError, ValueError, subprocess.SubprocessError): + return 0, 0 + + +def _encode(rid: str, fps: int, audio: list[dict], crf: int = 18): + """audio = [{file: abs path, start: sec, gain: float, in?, out?}] (resolved). + crf 18 = deliverable; film SHOTS pass 14 — they get concatenated (re-encoded) + into the film, and two crf-18 generations chew gradients and plate grain.""" d = _render_dir(rid) _renders[rid] = {"state": "encoding", "log": ""} + try: + meta = json.loads((d / "meta.json").read_text()) + except (OSError, json.JSONDecodeError): + meta = {} + # A capture SMALLER than the declared deliverable is a client failure, not a + # render: three.js keeps drawing at whatever buffer it actually got (a lost + # WebGL context, a clamped canvas on a small-memory box) and vf_args' scale + # would happily UPSCALE it to a soft, mushy mp4 with state=done and no log + # line anywhere. One ffprobe of frame 0 turns that into a visible failure. + # Bigger is normal — that is the supersample. + try: + dw, dh = int(meta.get("width", 0)), int(meta.get("height", 0)) + except (TypeError, ValueError): + dw = dh = 0 + first = next(iter(sorted((d / "frames").glob("*.png"))), None) + if first is not None and dw >= 16 and dh >= 16: + fw, fh = _png_size(first) + if fw and fh and (fw < dw or fh < dh): + _renders[rid] = {"state": "error", "log": + f"frames are {fw}x{fh} but this render declared {dw}x{dh}: the " + f"browser never got the drawing buffer it asked for (lost WebGL " + f"context / clamped canvas). Upscaling would hide it — refusing."} + return cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png")] for a in audio: cmd += audio_input(a) # in/out trim rides on the input (M9) - cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"] + # -vf (picture) before the audio -filter_complex: different streams, they + # coexist because the video map is a plain input, not a complex-graph label. + cmd += vf_args(meta) + video_args(crf) if audio: # delay to start, apply gain, mix down to one stereo stream cmd += ["-filter_complex", ";".join(audio_filters(audio, 1)), "-map", "0:v", "-map", "[aout]", "-c:a", "aac", "-shortest"] @@ -438,7 +549,15 @@ async def render_frame(rid: str, n: int, request: Request): raise HTTPException(404, "unknown renderId") if not (0 <= n <= 20000): raise HTTPException(400, "frame index out of range") - body = await capped_body(request, 25 * 1024 * 1024) + # 40MB, not 25: render.js supersamples 2x, so a 1080p deliverable posts + # 3840x2160 PNGs and a detailed frame gets close to the old cap. Every + # OTHER capped_body limit stays where it is. + # NOTE (prod): this is NOT the binding limit at digalot.fyi — + # deploy/nginx-scenegod.conf sets client_max_body_size 30m in front of it, + # so a frame between 30MB and 40MB 413s in nginx with no message from here. + # Typical 4K PNG frames measure ~3MB, so nothing hits either wall today; + # deploy/ is not Lane C's to edit — logged as a REQUEST in logs/laneC.md. + body = await capped_body(request, 40 * 1024 * 1024) if not body: raise HTTPException(400, "empty frame body") (frames / f"{n:06d}.png").write_bytes(body) @@ -466,7 +585,11 @@ async def render_end(rid: str, request: Request): if err: raise HTTPException(400, f"audio: {err}") audio.append(e) - threading.Thread(target=_encode, args=(rid, fps, audio), daemon=True).start() + # film.js flags its per-shot renders: they are intermediates that get + # re-encoded by the concat, so they encode at crf 14 (one lossy generation + # that matters, not two). Not client-tunable beyond that boolean. + crf = 14 if body.get("intermediate") else 18 + threading.Thread(target=_encode, args=(rid, fps, audio, crf), daemon=True).start() return {"ok": True} @@ -720,7 +843,12 @@ def _concat_film(fid: str): cmd += [x for a in meta["audio"] for x in audio_input(a)] # in/out trim (M9) # normalise every shot to the film's grid — mismatches are rejected at # registration, this is the belt to that braces (SAR/fps oddities included). - parts = [f"[{i}:v]scale={w}:{h},setsar=1,fps={fps},format=yuv420p[v{i}]" for i in range(len(srcs))] + # CONVERT here is belt-and-braces: the shots are already bt709 YUV so it is a + # no-op, but a shot mp4 left in renders/ by an older build (or any hand-dropped + # file) is bt601, and mixing matrices inside one xfade is invisible until it + # isn't. It never touches an already-bt709 input. + parts = [f"[{i}:v]scale={w}:{h}:{CONVERT},setsar=1,fps={fps},format=yuv420p[v{i}]" + for i in range(len(srcs))] cur, acc = "v0", durs[0] for i in range(1, len(srcs)): prev = shots[i - 1] @@ -733,11 +861,15 @@ def _concat_film(fid: str): parts.append(f"[{cur}][v{i}]concat=n=2:v=1:a=0[x{i}]") acc += durs[i] cur = f"x{i}" + parts.append(f"[{cur}]{SETPARAMS}[vout]") # bt709 on the FRAMES (see SETPARAMS) parts += audio_filters(meta["audio"], len(srcs)) # delay/gain/mix the film-level bed - cmd += ["-filter_complex", ";".join(parts), "-map", f"[{cur}]"] + cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]"] if meta["audio"]: cmd += ["-map", "[aout]", "-c:a", "aac", "-shortest"] # apad+shortest: video sets the length - cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", str(d / "out.mp4")] + # shots were encoded at crf 14 so this — the only pass anyone watches — is + # the only one that meaningfully quantises. Each shot is already scaled to + # the film grid in the filter_complex above, so no -vf here. + cmd += video_args(18) + [str(d / "out.mp4")] try: p = subprocess.run(cmd, capture_output=True, text=True) ok = p.returncode == 0 and (d / "out.mp4").is_file() diff --git a/scenegod/web/dock.js b/scenegod/web/dock.js index 9c7268a..5cbb10e 100644 --- a/scenegod/web/dock.js +++ b/scenegod/web/dock.js @@ -8,6 +8,64 @@ const TABS = ['characters', 'props', 'backdrops', 'animations']; const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // ship-check: XSS +// What a 🗑 delete would destroy, and whether that is worth a confirm dialog. +// stage.removeEntity → Timeline._mirror splices the entity out of scene.entities, taking its +// transform/param/morph keys and clip blocks with it, and nothing pushes an undo entry — Cmd+Z +// cannot bring it back. So count the work first and only ask when there IS work; a freshly added, +// unkeyed entity stays one click, which is the common case. +// CAMERA CUTS COUNT AS WORK: "never confirm for a camera" let one unconfirmed click leave a cut +// pointing at a dead id, and the server rejects that scene with 422 on every save afterwards. +// Returns the cut OBJECTS too, so the caller can take them with the camera. +export function deleteWarning(tl, en){ + const e = tl && tl.scene && (tl.scene.entities || []).find(x => x.id === en.id); + const keys = (e?.tracks?.transform?.length||0) + (e?.tracks?.params?.length||0) + + (e?.tracks?.morphs?.length||0); + const clips = e?.tracks?.clips?.length||0; + const cutObjs = (tl && tl.scene && (tl.scene.cameraCuts || []) || []).filter(c => c.camera === en.id); + // no timeline mounted → no count to show: ask for meshes, never for a camera/light + const ask = tl ? (keys + clips + cutObjs.length > 0) + : (en.kind === 'character' || en.kind === 'prop'); + // Only what is actually there, singularised: "0 keyframes, 0 clip blocks, 1 camera cut go with + // it" both listed nothing and disagreed with itself. And say the part that matters most — + // nothing about this delete is undoable (see dropCuts). + const n = (c, one, many) => `${c} ${c === 1 ? one : many}`; + const bits = []; + if(keys) bits.push(n(keys, 'keyframe', 'keyframes')); + if(clips) bits.push(n(clips, 'clip block', 'clip blocks')); + if(cutObjs.length) bits.push(n(cutObjs.length, 'camera cut', 'camera cuts')); + const plural = bits.length > 1 || keys > 1 || clips > 1 || cutObjs.length > 1; + const msg = bits.length + ? `Delete "${en.label}"? ${bits.join(' and ')} ${plural ? 'go' : 'goes'} with it. This cannot be undone.` + : `Delete "${en.label}"? This cannot be undone.`; + return { keys, clips, cuts: cutObjs.length, cutObjs, ask, msg }; +} + +// Take a deleted camera's cameraCuts with it — WITHOUT leaving an undo entry behind. +// A delete spans two subsystems and only one of them has an undo: Stage.removeEntity pushes +// nothing, and Timeline._mirror splices the entity + its tracks out just as silently. Removing the +// cuts inside `tl.group()` (what this used to do) therefore made the delete HALF-undoable: one +// Ctrl+Z put the cut back without the camera it points at — a dangling cut, i.e. exactly the +// `cameraCut references non-camera entity` 422 the cleanup exists to prevent, one keystroke away +// and with no second undo to fix it. Symmetry is the rule: the entity cannot come back, so neither +// may its cuts. `undoStack` is Lane B's cross-lane surface for exactly this kind of bookkeeping +// (tlui marks and collapses it around a drag); removeCut is still the only thing that touches the +// model, so `_activeCam` invalidation etc. stay Lane B's. +export function dropCuts(tl, cuts){ + if(!tl || typeof tl.removeCut !== 'function' || !cuts || !cuts.length) return 0; + const mark = Array.isArray(tl.undoStack) ? tl.undoStack.length : -1; + for(const c of cuts) tl.removeCut(c); + if(mark >= 0) tl.undoStack.length = mark; // the cuts left with the camera; they don't come back + return cuts.length; +} + +// Is this param name driven by a key? Then the inspector field is a request, not a setting: +// Timeline.syncFromStage resets the model to the authored value on the same tick and the next +// evaluate() writes the key's value over the stage. Say so instead of letting the number bounce. +export function keyedParam(tl, id, key){ + const e = tl && tl.scene && (tl.scene.entities || []).find(x => x.id === id); + return !!(e && (e.tracks?.params || []).some(p => p.key === key)); +} + export class Dock { constructor({ stage, dockEl, inspectorEl, viewEl }){ this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl; @@ -23,29 +81,40 @@ export class Dock { } async load(){ + const empty = { characters:[], props:[], backdrops:[], animations:[] }; + this._offline = false; this._loadErr = null; try { const r = await fetch('assets/tree'); - if(!r.ok) throw new Error(r.status); - this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) }; + if(r.ok) this.tree = { ...empty, ...(await r.json()) }; + else { + // a REACHED server that answered 500 is not "offline" — SCENEGOD_ASSETS pointing at a + // missing dir says so in the body, and collapsing that into "server offline" sent every + // prod volume mis-mount to the wrong place to look. + this._loadErr = r.status + ': ' + (await r.text().catch(() => '')).slice(0, 120); + this.tree = { ...empty }; + } } catch(e){ - this._offline = true; - this.tree = { characters:[], props:[], backdrops:[], animations:[] }; + this._offline = true; // fetch threw = nothing answered + this.tree = { ...empty }; } this.renderBrowser(); } _buildTabs(){ const add = document.createElement('div'); add.className = 'addbar'; - for(const [txt, what] of [['+ camera','camera'], ['+ key light','keylight'], ['+ ambient','ambient']]){ - const b = document.createElement('button'); b.textContent = txt; + for(const [txt, what, tip] of [['+ camera (from view)','camera','stamps a camera at the orbit view you are looking at now'], + ['+ key light','keylight',''], ['+ ambient','ambient','']]){ + const b = document.createElement('button'); b.textContent = txt; if(tip) b.title = tip; b.onclick = () => this._addRig(what).catch(e => this._toast('failed: ' + String(e).slice(0,80))); add.appendChild(b); } this.dock.appendChild(add); const bar = document.createElement('div'); bar.className = 'tabs'; + this._tabBtns = {}; for(const t of TABS){ - const b = document.createElement('button'); b.textContent = t.toUpperCase(); + const b = this._tabBtns[t] = document.createElement('button'); + b.innerHTML = t.toUpperCase() + '0'; // TABS is a module constant — nothing to escape b.className = t === this.tab ? 'on' : ''; b.onclick = () => { this.tab = t; [...bar.children].forEach(c => c.classList.toggle('on', c === b)); this.renderBrowser(); }; bar.appendChild(b); @@ -94,11 +163,17 @@ export class Dock { renderBrowser(){ this.list.innerHTML = ''; + for(const t of TABS){ const b = this._tabBtns && this._tabBtns[t]; + if(b) b.innerHTML = t.toUpperCase() + '' + ((this.tree[t] || []).length) + ''; } const items = this.tree[this.tab] || []; if(!items.length){ - this.list.innerHTML = `
${this._offline - ? 'server offline — drag a .glb / .fbx / image onto the view' - : 'nothing here — drop banks into assets/' + this.tab + '/'}
`; + this.list.innerHTML = this._offline + ? '
server offline — drag a .glb / .fbx / image onto the view
' + : this._loadErr + ? `
assets/tree failed
${esc(this._loadErr)}
` + // "drop banks into assets/…" was an instruction you cannot follow from a browser + : `
no ${esc(this.tab)} on the server yet — drag a .glb / .fbx / image ` + + 'from your desktop onto the stage
'; return; } const icon = { characters:'🕺', props:'🪑', backdrops:'🖼', animations:'🏃' }[this.tab]; @@ -171,8 +246,12 @@ export class Dock { // cameras and lights have no asset file — spawn them from the toolbar async _addRig(what){ + // a camera spawns ON THE VIEW you composed — the old hardcoded [0,1.6,4] meant orbiting to a + // framing and then guessing it back with the gizmo. + const cs = what === 'camera' && this.stage.directorCamState ? this.stage.directorCamState() : null; const D = { - camera: { kind:'camera', label:'camera', params:{ fov:45 }, transform:{ pos:[0,1.6,4], rot:[0,0,0], scale:1 } }, + camera: { kind:'camera', label:'camera', params:{ fov: cs ? cs.fov : 45 }, + transform: cs ? { pos:cs.pos, rot:cs.rot, scale:1 } : { pos:[0,1.6,4], rot:[0,0,0], scale:1 } }, keylight: { kind:'light', label:'key light', params:{ type:'key', color:'#ffffff', intensity:2.2, castShadow:true }, transform:{ pos:[3,5,2], rot:[-0.9,0.5,0], scale:1 } }, ambient: { kind:'light', label:'ambient', params:{ type:'ambient', color:'#ffffff', intensity:1.0 } }, }[what]; @@ -182,6 +261,31 @@ export class Dock { } // ---- inspector ---- + // EVERY inspector param edit goes through here. The value is live on the stage immediately, but + // the timeline keeps its OWN snapshot of each entity's params (taken once by _mirror, at drop + // time) and evaluates from that — so typing 2 into `vid start` moved the RENDERED frame + // (stage.syncVideos reads the stage) while the scrubbed viewport stayed on 0 + // (Timeline._evalVideo reads the snapshot): the same param meaning two different things. + // syncFromStage is Lane B's own "write the stage back into the model" pass; running it on the + // edit instead of only at save time is what actually makes "what you scrub is what you render" + // true, rather than just sharing the arithmetic. + _param(id, key, value){ + this.stage.setParam(id, key, value); + const tl = typeof window !== 'undefined' && window.timeline; + if(tl && tl.syncFromStage) tl.syncFromStage(); + // …and if the channel is KEYED, that sync just threw the typed number away (and the next + // evaluate will overwrite the stage too). Silently. Say it: a light preset makes `exposure` + // a keyed param, and "type it into the ambient inspector" was the documented way to fix a + // level — it is now "⏺ key", and nothing said so. + // Said through tlui's toast channel (window.sgToast) when it exists, because that is where + // " is keyed — press ⏺ key…" already goes for a gizmo drag: one channel, one banner, + // and a duplicate from the timeline's own side simply overwrites it rather than stacking. + if(keyedParam(tl, id, key)){ + const m = `${key} is keyed — the key at the playhead wins. Press ⏺ key to change it there.`; + (typeof window !== 'undefined' && window.sgToast) ? window.sgToast(m) : this._toast(m); + } + } + renderInspector(en){ this._selId = en ? en.id : null; const el = this.insp; el.innerHTML = ''; @@ -192,9 +296,31 @@ export class Dock { en.source && en.source.type === 'upload' ? ' · upload' : ''}`; el.appendChild(h); - el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v }))); - el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v }))); - el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v }))); + // setTransform hard-returns for a frustum-fitted plate (the lens owns it), so nine number + // fields that look like everyone else's were silently discarding everything typed into them. + if(en.kind === 'backdrop' && en.params.fit === 'frustum'){ + const n = document.createElement('div'); n.className = 'inote'; + n.textContent = 'placed by the camera — switch fit to "free" to position it by hand'; + el.appendChild(n); + } else if(en.kind === 'light'){ + // A key light aims at the stage centre (stage.js _buildLight: the target is nailed to the + // origin so the ortho shadow box, measured from the origin, stays over the stage). Its + // ANGLE therefore IS its position — elevation/azimuth on a boom, exactly what the light + // presets solve. `rot`/`scale` fields would have been three number rows that change + // nothing, so they are a sentence instead. An ambient (hemisphere) has no transform at all. + const n = document.createElement('div'); n.className = 'inote'; + if(en.params.type === 'ambient'){ + n.textContent = 'ambient light — it comes from everywhere: no position, no angle'; + } else { + el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v }))); + n.textContent = 'aims at the stage centre — MOVE it to change the light angle (rotating does nothing)'; + } + el.appendChild(n); + } else { + el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v }))); + el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v }))); + el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v }))); + } if(en.kind === 'character'){ const vis = this.stage.visemeTargets ? this.stage.visemeTargets(en.id) : []; @@ -205,49 +331,68 @@ export class Dock { el.appendChild(badge); } if(en.kind === 'camera'){ - el.appendChild(this._num('fov', en.params.fov ?? 45, v => this.stage.setParam(en.id, 'fov', v))); + el.appendChild(this._num('fov', en.params.fov ?? 45, v => this._param(en.id, 'fov', v))); const act = document.createElement('button'); act.textContent = '◉ set active camera'; act.className = 'wide'; act.onclick = () => this.stage.setActiveCamera(en.id); el.appendChild(act); } if(en.kind === 'light'){ - el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this.stage.setParam(en.id, 'intensity', v))); - el.appendChild(this._color('color', en.params.color || '#ffffff', v => this.stage.setParam(en.id, 'color', v))); - if(en.params.type === 'ambient') // M7: keyable atmosphere (0 = off), coloured from the plate - el.appendChild(this._num('fog', en.params.fog ?? 0.02, v => this.stage.setParam(en.id, 'fog', v))); + el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this._param(en.id, 'intensity', v))); + el.appendChild(this._color('color', en.params.color || '#ffffff', v => this._param(en.id, 'color', v))); + if(en.params.type === 'ambient'){ // M7: keyable atmosphere (0 = off), coloured from the plate + el.appendChild(this._num('fog', en.params.fog ?? 0.02, v => this._param(en.id, 'fog', v))); + // ACES level for the WHOLE frame — rides the ambient entity like bg/fog (keyable, persists) + el.appendChild(this._num('exposure', en.params.exposure ?? 1, + v => this._param(en.id, 'exposure', v))); + } } if(en.kind === 'backdrop'){ el.appendChild(this._sel('mode', ['plane','corner','dome','video'], en.params.mode || 'plane', - v => this.stage.setParam(en.id, 'mode', v))); // stage rebuilds the mesh on change + v => this._param(en.id, 'mode', v))); // stage rebuilds the mesh on change el.appendChild(this._sel('fit', ['frustum','free'], en.params.fit || 'free', - v => { this.stage.setParam(en.id, 'fit', v); this.renderInspector(en); })); + v => { this._param(en.id, 'fit', v); this.renderInspector(en); })); if(en.params.fit === 'frustum'){ - el.appendChild(this._num('fit dist', en.params.fitDist ?? 14, v => this.stage.setParam(en.id, 'fitDist', v))); + el.appendChild(this._num('fit dist', en.params.fitDist ?? 14, v => this._param(en.id, 'fitDist', v))); // which fraction of the plate sits on the lens axis: 1 = top edge at eye level (void above), // 0.5 = dead centre, 0.75 = a typical plate's horizon at eye level (SYNC7 default) el.appendChild(this._range('anchor', en.params.fitAnchor ?? 0.75, - v => this.stage.setParam(en.id, 'fitAnchor', v))); + v => this._param(en.id, 'fitAnchor', v))); const re = document.createElement('button'); re.textContent = '⤢ refit to camera'; re.className = 'wide'; - re.onclick = () => this.stage.setParam(en.id, 'fit', 'frustum'); // after moving the camera + re.onclick = () => this._param(en.id, 'fit', 'frustum'); // after moving the camera el.appendChild(re); } + // plates are UNLIT (they are display-referred photographs, not surfaces) — this is their + // only level control, 1 = the image as shot. + el.appendChild(this._num('plate exp', en.params.plateExposure ?? 1, + v => this._param(en.id, 'plateExposure', Math.max(0, Math.min(2, v))))); el.appendChild(this._chk('ground tint', en.params.groundTint ?? true, - v => this.stage.setParam(en.id, 'groundTint', v))); + v => this._param(en.id, 'groundTint', v))); if(en.video) el.appendChild(this._num('vid start', en.params.videoStart ?? 0, - v => this.stage.setParam(en.id, 'videoStart', v))); + v => this._param(en.id, 'videoStart', v))); } if(en.kind === 'screen'){ - el.appendChild(this._num('width', en.params.width ?? 3, v => this.stage.setParam(en.id, 'width', v))); - el.appendChild(this._num('emissive', en.params.emissive ?? 0.6, v => this.stage.setParam(en.id, 'emissive', v))); + el.appendChild(this._num('width', en.params.width ?? 3, v => this._param(en.id, 'width', v))); + el.appendChild(this._num('emissive', en.params.emissive ?? 0.6, v => this._param(en.id, 'emissive', v))); el.appendChild(this._num('vid start', en.params.videoStart ?? 0, - v => this.stage.setParam(en.id, 'videoStart', v))); + v => this._param(en.id, 'videoStart', v))); } const row = document.createElement('div'); row.className = 'ibtns'; const key = document.createElement('button'); key.textContent = '⏺ key'; key.title = 'capture keyframe'; key.onclick = () => dispatchEvent(new CustomEvent('scenegod:capturekey', { detail:{ id: en.id } })); const del = document.createElement('button'); del.textContent = '🗑 delete'; del.className = 'danger'; - del.onclick = () => { this.stage.removeEntity(en.id); this.renderInspector(null); }; + del.onclick = () => { + const tl = window.timeline; + const w = deleteWarning(tl, en); + if(w.ask && !confirm(w.msg)) return; + this.stage.removeEntity(en.id); + // Timeline._mirror drops the entity and its tracks, but a cameraCut pointing at it survives + // and the scene then fails server validation ("cameraCut references non-camera entity") — + // the whole scene becomes unsaveable until the user hunts the cut down by hand. The cuts + // leave with the camera, and leave the same way it did: not undoably (see dropCuts). + dropCuts(tl, w.cutObjs); + this.renderInspector(null); + }; row.append(key, del); el.appendChild(row); } diff --git a/scenegod/web/film.js b/scenegod/web/film.js index 1267431..14fda2b 100644 --- a/scenegod/web/film.js +++ b/scenegod/web/film.js @@ -17,7 +17,7 @@ // own audio[] (mixed server-side over the finished cut), not per-scene audio — // the server warns when a referenced scene has audio[] that will be skipped. // All URLs relative (the app serves under /scenegod/ in prod). -import { withRenderSize, beginRender, captureFrames, endRender, pollRender, +import { withRenderSize, ssFactor, beginRender, captureFrames, endRender, pollRender, postJSON, getJSON } from './render.js'; const sleep = ms => new Promise(r => setTimeout(r, ms)); @@ -30,8 +30,11 @@ export async function renderSequence(stage, timeline, seqName, opts = {}) { if (!shots?.length) throw new Error('sequence has no shots'); // one canvas resize for the whole film — shots must all be the same size, the - // server rejects a mismatched shot at registration anyway. - await withRenderSize(stage, width, height, async () => { + // server rejects a mismatched shot at registration anyway. Rendered at ss x the + // deliverable (2 up to 1080p) and downscaled by ffmpeg; begin still declares the + // OUTPUT size, so meta.json and the film grid keep matching. + const ss = ssFactor(width, height, opts.ss); + await withRenderSize(stage, width * ss, height * ss, async () => { for (const shot of shots) { const i = shot.index; onShot?.(i, shots.length, shot); @@ -42,7 +45,7 @@ export async function renderSequence(stage, timeline, seqName, opts = {}) { await captureFrames(stage, timeline, { renderId, from, to, onProgress: (done, total) => onProgress?.({ shot: i, of: shots.length, scene: shot.scene, frame: done, frames: total }) }); - await endRender(renderId); // silent: the bed is film-level + await endRender(renderId, [], { intermediate: true }); // silent (bed is film-level), crf 14 await pollRender(renderId); // shot mp4 must exist before we register it await postJSON(`films/${filmId}/shot/${i}`, { renderId }); } diff --git a/scenegod/web/grammar.js b/scenegod/web/grammar.js index 0cafb5a..37fc7f3 100644 --- a/scenegod/web/grammar.js +++ b/scenegod/web/grammar.js @@ -29,22 +29,28 @@ export const ANGLES = { export const FOCALS = [18, 24, 35, 50, 85]; // Lighting looks: key ("sun") color/intensity + boom direction (degrees), hemisphere -// ambient, and a background clear color. Values are what applyLight writes verbatim. +// ambient, a background clear color, and `exposure` — the ACES tone-mapping level for the +// whole frame. Without it the presets sit ~6 stops apart in key with nothing rebalancing +// overall level: night/noir/horror read as black holes and day clips. It rides the ambient +// entity as an ordinary param (applyLight writes it like `bg`) and applyOps emits it as its +// own generic `light` op so the timeline KEYS it — the renderer's exposure is global, so an +// unkeyed one would leave the whole earlier stretch of a scene rendering at the later +// preset's level. Values are what applyLight writes verbatim. export const LIGHTS = { day: { sun: { color: '#fff6e4', intensity: 3.0, elev: 55, azim: 35 }, - ambient: { color: '#dfe9ff', intensity: 1.15 }, bg: '#8fb0d6' }, + ambient: { color: '#dfe9ff', intensity: 1.15 }, bg: '#8fb0d6', exposure: 1.0 }, golden_hour: { sun: { color: '#ffb469', intensity: 2.6, elev: 9, azim: -55 }, - ambient: { color: '#ffd9b0', intensity: 0.55 }, bg: '#d98a52' }, + ambient: { color: '#ffd9b0', intensity: 0.55 }, bg: '#d98a52', exposure: 1.1 }, night: { sun: { color: '#7e93d6', intensity: 0.6, elev: 40, azim: 150 }, - ambient: { color: '#31405e', intensity: 0.4 }, bg: '#0a0e1a' }, + ambient: { color: '#31405e', intensity: 0.4 }, bg: '#0a0e1a', exposure: 1.6 }, noir: { sun: { color: '#ffffff', intensity: 3.4, elev: 52, azim: 75 }, - ambient: { color: '#1c1c24', intensity: 0.15 }, bg: '#08080a' }, + ambient: { color: '#1c1c24', intensity: 0.15 }, bg: '#08080a', exposure: 1.2 }, horror: { sun: { color: '#b9d6a4', intensity: 1.7, elev: -30, azim: 10 }, // underlight - ambient: { color: '#25302b', intensity: 0.25 }, bg: '#05070a' }, + ambient: { color: '#25302b', intensity: 0.25 }, bg: '#05070a', exposure: 1.4 }, neon: { sun: { color: '#ff4fd8', intensity: 1.9, elev: 25, azim: 120 }, - ambient: { color: '#2de2e6', intensity: 0.9 }, bg: '#150420' }, + ambient: { color: '#2de2e6', intensity: 0.9 }, bg: '#150420', exposure: 1.3 }, overcast: { sun: { color: '#dfe4ea', intensity: 1.2, elev: 70, azim: 0 }, - ambient: { color: '#c7cdd6', intensity: 1.4 }, bg: '#9aa3ad' }, + ambient: { color: '#c7cdd6', intensity: 1.4 }, bg: '#9aa3ad', exposure: 1.0 }, }; // Camera MOVES (M11): each one solves to a START and an END camera state off the same subject. diff --git a/scenegod/web/index.html b/scenegod/web/index.html index 6dabfda..c88e08b 100644 --- a/scenegod/web/index.html +++ b/scenegod/web/index.html @@ -50,9 +50,10 @@ const dock = new Dock({ }); // DIRECT panel (M5 director mode) — guarded like the timeline: page survives its absence +let direct = null; try { const { mountDirectPanel } = await import('./web/presets.js'); - mountDirectPanel(stage, document.getElementById('direct')); + direct = mountDirectPanel(stage, document.getElementById('direct')); } catch(e){ console.warn('direct panel not mounted:', e); } // Timeline is optional — the page must never break when Lane B's file is absent @@ -62,6 +63,10 @@ try { window.timeline = new Timeline(stage); window.tlui = new TimelineUI(window.timeline, document.getElementById('timeline')); stage.setMixerAuto(false); // timeline owns mixer stepping from here on + // tlui's render-size + + + + + `; this.el.appendChild(bar); + this.$bar = bar; // whole bar goes dead during a capture (_barBusySet) this.$play = bar.querySelector('[data-act="play"]'); this.$clock = bar.querySelector('.clock'); this.$name = bar.querySelector('.name'); @@ -196,7 +336,11 @@ export class TimelineUI { this.$snapStep = bar.querySelector('.snapstep'); this.$play.onclick = () => this._toggle(); bar.querySelector('[data-act="save"]').onclick = () => this.save(); - bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value); + bar.querySelector('[data-act="load"]').onclick = () => this._pickScene(); + this.$rsize = bar.querySelector('.rsize'); + this.$rstat = bar.querySelector('.rstat'); + this.$renderBtn = bar.querySelector('[data-act="render"]'); + this.$renderBtn.onclick = () => this.render(); this.$audioBtn = bar.querySelector('[data-act="audio"]'); this.$audioBtn.onclick = () => this._pickAudio(); this.$cutBtn = bar.querySelector('[data-act="autocut"]'); @@ -261,6 +405,26 @@ export class TimelineUI { return rows; } + // stage.onChange fires on add, on remove, and on a gizmo mouseUp (stage.js's + // TransformControls 'mouseUp' → _fireChange — NOT from setTransform, so + // nothing here can feed back into the timeline). + _stageChanged(en) { + this._syncRows(); + if (!en || !en.id || this.tl._applying) return; + // A keyed entity's pose is re-applied on EVERY seek, so the drag you just did + // looks right until the next scrub and then silently reverts. Don't auto-key + // (too opinionated) — say so. + if (!rowFlags(this._te(en.id)).keyed) return; // fresh adds have no keys → silent + this._toast(`${en.label || en.id} is keyed — press ⏺ key in the inspector, or double-click its lane, to keep this pose`); + } + _markSel() { + if (!this.rows) return; + [...this.$names.children].forEach((d, i) => { + const r = this.rows[i]; + d.classList.toggle('sel', !!(this._selId && r && r.id === this._selId)); + }); + } + _syncRows() { this.rows = this._rows(); this.$names.innerHTML = ''; @@ -287,8 +451,27 @@ export class TimelineUI { lip.onclick = () => this._lipSync(r.clip, lip); d.appendChild(lip); } else d.textContent = r.label; + // OUTLINER: an ambient light has no clickable geometry on the stage (its + // wrapper holds a HemisphereLight, which the raycast can never hit), so its + // intensity / colour / bg / fog fields were reachable only from devtools. + // Every row that has an entity selects it — sub-rows select their parent. + if (r.id) { + const te = this._te(r.id); + const f = rowFlags(te); + d.classList.add('pick'); + d.title = `${r.label} — ${(r.kind || (te && te.kind) || 'entity')} ${r.id}` + + (f.keyed ? ` · ${te.tracks.transform.length} transform key(s)` : '') + ' · click to select'; + d.onclick = () => { if (this.stage.getEntity(r.id)) this.stage.select(r.id); }; // may have been removed + if (f.keyed && r.type === 'transform') { + const kf = document.createElement('span'); + kf.className = 'kf'; kf.textContent = '⏺'; + kf.title = 'keyed: the timeline drives this transform — moving it by hand reverts on the next scrub'; + d.appendChild(kf); + } + } this.$names.appendChild(d); }); + this._markSel(); // size lanes to content so no row (esp. Cameras) is clipped as rows grow. // ponytail: grow-to-content; add a max-height+scroll only if a scene ever // has so many entities the panel dominates the viewport. @@ -355,7 +538,7 @@ export class TimelineUI { // lanes this.rows.forEach((r, i) => { const y = RULER_H + i * ROW_H; - c.fillStyle = i % 2 ? '#161a20' : '#13171d'; + c.fillStyle = LANE_BG[i % 2]; c.fillRect(0, y, w, ROW_H); c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke(); if (r.type === 'transform') this._drawKeys(c, r, y); @@ -450,7 +633,7 @@ export class TimelineUI { const fx = this._t2x(end - b.fade); c.fillStyle = 'rgba(231,175,104,0.35)'; c.fillRect(fx, y + 5, x1 - fx, ROW_H - 10); } - c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace'; + c.fillStyle = LABEL_COLOR; c.font = '10px ui-monospace'; c.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip(); c.fillText((b.path || '').split('/').pop(), x0 + 4, y + ROW_H / 2 + 3); c.restore(); this._hits.push({ kind: 'block', id: r.id, block: b, x0, x1, y }); @@ -461,9 +644,14 @@ export class TimelineUI { const cy = y + ROW_H / 2; for (const cut of this.tl.cameraCuts) { const x = this._t2x(cut.t); - c.fillStyle = '#e0af68'; c.fillRect(x - 1, y + 4, 2, ROW_H - 8); + c.fillStyle = CUT_COLOR; c.fillRect(x - 1, y + 4, 2, ROW_H - 8); c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill(); - c.fillStyle = '#161a20'; c.font = '9px ui-monospace'; c.fillText(cut.camera || '?', x + 6, cy + 3); + // the LABEL, not the raw id — the lane read "e3 e5 e7" while the names + // column right beside it said "cam2 / cam3". REVIEW-FIX: it was painted in + // the lane's own background hex, i.e. invisible; it is the cut's own amber. + const en = this.stage.getEntity(cut.camera) || this._te(cut.camera); + c.fillStyle = CUT_COLOR; c.font = '9px ui-monospace'; + c.fillText((en && en.label) || cut.camera || '?', x + 6, cy + 3); this._hits.push({ kind: 'cut', cut, x, y: cy }); } } @@ -529,7 +717,9 @@ export class TimelineUI { } _down(e) { + if (this._busy()) return; // a render is stepping this clock — don't fight it const { x, y } = this._local(e); + this._undoMark = this.tl.undoStack.length; // a drag = ONE undo entry (collapsed in _up) if (y < RULER_H) { this._drag = { kind: 'seek' }; this._seek(this._x2t(x)); return; } const h = this._hitAt(x, y); if (!h) { this._drag = { kind: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek @@ -550,7 +740,7 @@ export class TimelineUI { if (d.kind === 'seek') this._seek(t); else if (d.kind === 'key') { this.tl.moveKey(d.h.id, d.h.track, d.h.key, this._snap(t, e)); this.draw(); } else if (d.kind === 'multikey') { for (const s of d.orig) this.tl.moveKey(s.id, s.track, s.key, this._snap(s.t0 + (t - d.grabT), e)); this.draw(); } - else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); } + else if (d.kind === 'cut') { this.tl.moveCut(d.h.cut, this._snap(t, e)); this.draw(); } // undo-able like every other drag else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); } else if (d.kind === 'audio') { this.tl.moveAudio(d.h.clip, this._snap(t - d.grabT, e)); this.draw(); } else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); } @@ -573,9 +763,18 @@ export class TimelineUI { if (!d.moved) this._seek(this._x2t(d.x0)); // click on empty = seek else { this._selKeys = this._keysInRect(d.x0, d.y0, d.x, d.y); this.draw(); } } + // A dropped cut absorbs anything it landed on top of (addCut's half-frame + // rule), then the whole drag — every mousemove — collapses to ONE undo entry. + if (d.kind === 'cut') { + const eaten = this.tl.settleCut(d.h.cut); + if (eaten) this._toast(`merged ${eaten} cut${eaten === 1 ? '' : 's'} you dropped this one on`); + } + if (this._undoMark != null) { this.tl.collapseUndo(this._undoMark); this._undoMark = null; } + if (d.kind === 'cut') { this.tl.seek(this.tl.time); this.draw(); } // re-assert the live camera } _dbl(e) { + if (this._busy()) return; const { x, y } = this._local(e); const i = this._rowAtY(y); if (i < 0) return; const r = this.rows[i]; @@ -593,28 +792,86 @@ export class TimelineUI { } this.draw(); } else if (r.type === 'cameras') { - const cams = this.stage.entities().filter((en) => en.kind === 'camera'); - // M11-B: the camera you are LOOKING THROUGH wins. After a 🎬 move the stage - // is on the moved camera while the cut list still says the old one — and - // "add a cut here" then meant the wrong camera, which is the one mistake a - // cut must not make. Falls back to the cut-derived camera as before. - const cam = (this.stage.activeCamera && this.stage.activeCamera()) - || this.tl._activeCam || (cams[0] && cams[0].id); - if (cam) { this.tl.addCut(t, cam); this.tl.seek(this.tl.time); this.draw(); } + this._cutPick(t, e); } } + + // Double-click the Cameras row = cut to a camera at t. It used to cut to + // `stage.activeCamera()` — "the camera you are looking through" — but the + // TIMELINE drives that: `_evalCameraCuts` puts the stage back on the camera the + // EDIT has live at every seek, so one scrub after adding cam2 the gesture could + // only ever write a no-op cut on top of cam1, and with 2+ cameras and no beat + // grid (✂ auto-cut refuses without one) there was no way at all to cut to the + // second camera from the timeline. So: ASK. One camera → no menu, just cut. + _cutPick(t, ev) { + const cams = this.stage.entities().filter((en) => en.kind === 'camera'); + if (!cams.length) { this._toast('no cameras in this scene — add one from the dock (+ camera)'); return; } + if (cams.length === 1) { this._cutTo(t, cams[0].id); return; } + const live = this.tl.cutCameraAt(t); + const menu = document.createElement('div'); + menu.className = 'tl-menu'; + const hd = document.createElement('div'); + hd.className = 'empty'; hd.textContent = `cut at ${t.toFixed(2)}s to…`; + menu.appendChild(hd); + for (const cm of cams) { + const item = document.createElement('div'); + item.className = 'item'; + item.textContent = (cm.label || cm.id) + (cm.id === live ? ' (live here)' : ''); + // REVIEW-FIX: `menu.remove()` alone left the document mousedown listener + // registered (holding a detached node until the next click anywhere), and + // the menu was invisible to Escape because nothing on `this` pointed at it. + // One close path, tracked on `this`, like every other overlay in this file. + item.onclick = () => { this._pickClose(); this._cutTo(t, cm.id); }; + menu.appendChild(item); + } + this._pickClose(); // never two pickers at once + document.body.appendChild(menu); + menu.style.top = ((ev && ev.clientY != null ? ev.clientY : 100) + 4) + 'px'; + menu.style.left = Math.max(4, Math.min((ev && ev.clientX != null ? ev.clientX : 100), + window.innerWidth - menu.offsetWidth - 6)) + 'px'; + const away = (evt) => { if (!menu.contains(evt.target)) this._pickClose(); }; + this._pickMenu = menu; this._pickAway = away; + setTimeout(() => { if (this._pickMenu === menu) document.addEventListener('mousedown', away); }, 0); + } + _pickClose() { + if (!this._pickMenu) return; + if (this._pickAway) document.removeEventListener('mousedown', this._pickAway); + this._pickMenu.remove(); + this._pickMenu = null; this._pickAway = null; + } + _cutTo(t, camId) { + const label = (this.stage.getEntity(camId) || this._te(camId) || {}).label || camId; + const { action, at, dead } = cutAction(this.tl.cameraCuts, t, camId, this.tl.fps); + if (action === 'noop') { this._toast(`${label} is already live at ${t.toFixed(2)}s — no cut added`); return; } + // ONE undo entry for the whole gesture: the cut, plus the cut this edit just + // made redundant. Two entries would leave a half-undone edit (the dead cut + // back, the new one still there) one Ctrl+Z in. + const deadT = dead ? dead.t : null; + this.tl.group(() => { + if (action === 'remove') this.tl.removeCut(at); // the cut sitting here changed nothing + else this.tl.addCut(t, camId); // 'replace' is addCut's half-frame rule + if (dead) this.tl.removeCut(dead); + }); + const also = deadT != null ? ` · dropped the now-dead cut at ${deadT.toFixed(2)}s (${label} either side of it)` : ''; + if (action === 'remove') this._toast(`removed the cut at ${at.t.toFixed(2)}s — ${label} was already live${also}`); + else this._toast(`cut at ${t.toFixed(2)}s → ${label}` + (action === 'replace' ? ' (replaced the cut here)' : '') + also); + this.tl.seek(this.tl.time); + this.draw(); + } _ctx(e) { e.preventDefault(); + if (this._busy()) return; const { x, y } = this._local(e); const h = this._hitAt(x, y); if (h && h.kind === 'key') { this.tl.deleteKey(h.id, h.track, h.key); this._selKeys = this._selKeys.filter((s) => s.key !== h.key); this.draw(); } - else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); } + else if (h && h.kind === 'cut') { this.tl.removeCut(h.cut); this.tl.seek(this.tl.time); this.draw(); } // undo-able (was a raw splice) else if (h && h.kind === 'audioblock') { this.tl.removeAudio(h.clip); this._syncRows(); } } _key(e) { if (e.target && (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.target.isContentEditable)) return; if (e.key === '?') { e.preventDefault(); this._toggleHelp(); } // never hijacked while typing - else if (e.key === 'Escape') { this._helpClose(); this._cutMenuClose(); } + else if (e.key === 'Escape') { this._helpClose(); this._cutMenuClose(); this._pickClose(); } // ✂ panel AND the camera picker + else if (this._busy()) return; // a render owns the clock: no play, no seek, no edits else if (e.code === 'Space') { e.preventDefault(); this._toggle(); } else if (e.key === 'Home') this._seek(0); else if (e.key === 'End') this._seek(this.tl.duration); @@ -626,6 +883,7 @@ export class TimelineUI { } } _toggle() { + if (this._busy()) return; // ▶ during a capture would race the frame stepper if (this.tl.playing) { this.tl.pause(); this._audioStop(); } else { this.tl.play(); this._audioStart(); } this._wasPlaying = this.tl.playing; @@ -764,12 +1022,15 @@ export class TimelineUI { ]) + S('TIMELINE', [ ['Space', 'play / pause'], + ['click a name-column row', 'select that entity on the stage (the only way to reach an ambient light)'], + ['⏺ beside a name', 'the timeline drives that transform — a gizmo move reverts on the next scrub'], ['Home / End', 'jump to start / end'], ['click or drag the ruler', 'scrub'], ['double-click a transform lane', 'key the object where it stands now'], ['double-click a params lane', 'key its current params (fov, intensity, colour…)'], - ['double-click the Cameras row', 'cut to the camera you are looking through'], - ['drag a key', 'move it — snaps to the increment, Alt = free'], + ['double-click the Cameras row', 'cut to a camera here — pick which one (one camera = straight in)'], + ['drag a key', 'move it — snaps to the increment, Alt = free (a whole drag = one undo)'], + ['drag a cut', 'move it; dropped within a frame of another cut it absorbs it'], ['drag empty lane space', 'box-select keys; drag one of them to move them all'], ['Delete / Backspace', 'delete the selected keys'], ['right-click', 'delete a key, a cut, or an audio block'], @@ -782,7 +1043,8 @@ export class TimelineUI { ]) + S('SCENE BAR', [ ['New…', 'start from a starter template (asks before clobbering)'], - ['Save / Load', 'scenes by name (falls back to localStorage offline)'], + ['Save / Load', 'Save says plainly if the server refused it · Load lists what is on the server (asks before clobbering)'], + ['⏺ Render', 'render THIS scene to mp4 at the picked size, with its audio'], ['dur / snap', 'scene length · snap increment — frame/0.1/0.25/1s, or beat/bar once ♪ found a grid'], ['🎵 / ♪ / 🗣', 'add audio at the playhead · beat-detect it · lip-sync it onto a character'], ['✂', 'auto-cut: camera cuts on the downbeats, cameras cycling — one undo'], @@ -1174,6 +1436,7 @@ export class TimelineUI { _filmBusySet(b) { this._filmBusy = b; for (const nEl of this.$film.querySelectorAll('button,select,input')) nEl.disabled = b; + this._barBusySet(b); // the scene bar drives the SAME timeline — Save/Load/▶ off too this.$render.textContent = b ? 'rendering…' : 'Render film'; if (!b) this._filmEnable(); } @@ -1271,16 +1534,26 @@ export class TimelineUI { // tl.applyScene), so it clobbers whatever is open — same warn-before-clobber // contract as New-from-template, said plainly. One render at a time. async _filmRender() { - if (!this._seq || this._filmBusy) return; + if (!this._seq) return; + // REVIEW-FIX: the busy flag used to be claimed only AFTER `await + // this._filmSave()` — i.e. after a network round-trip during which + // `_rendering`/`_filmBusy` were both still false and the scene bar was still + // live, so a ⏺ Render click walked straight through `renderRefusal` and both + // capture loops ended up stepping the same clock (and whichever finished + // first called resume() + re-armed the bar under the other one). The gate is + // now claimed SYNCHRONOUSLY, before the first await, and released in a + // finally that covers every exit — including "the save failed". + const no = renderRefusal({ rendering: this._rendering, filmBusy: this._filmBusy }); + if (no) { this._filmStatus(no, true); return; } if (!(this._seq.shots || []).length) { this._filmStatus('nothing to render — add a shot', true); return; } if (this.tl.hasContent() && !confirm( 'Render this film?\n\nEach shot loads its own saved scene onto the stage in turn, so the scene you have open right now will be REPLACED and any unsaved work is lost. Save it first if you want to keep it.')) return; - const saved = await this._filmSave(); // the server renders the SAVED shot list, not this editor - if (!saved) return; const [w, h] = (this.$size.value || '1920x1080').split('x').map(Number); - this._filmBusySet(true); - this._filmStatus('starting…'); + this._filmBusySet(true); // claimed BEFORE the first await try { + const saved = await this._filmSave(); // the server renders the SAVED shot list, not this editor + if (!saved) return; // _filmSave already said why; the finally hands the gate back + this._filmStatus('starting…'); const F = await this._filmMod(); const url = await F.renderSequence(this.stage, this.tl, this._seqName, { width: w, height: h, @@ -1301,28 +1574,196 @@ export class TimelineUI { } } + // ---- ⏺ Render: the scene you are looking at, to mp4, with its audio ------- + // render.js's finalRender() had ZERO callers — the only render control in the + // app was "Render film" inside the collapsed 🎬 panel, which needs a saved + // scene, a sequence and a shot before it will do anything, renders SILENT by + // design, and replaces the open scene when it's done. This is the one-button + // version: this scene, this size, its audio[], nothing clobbered. + // render.js is imported LAZILY, exactly like film.js — the panel must still + // mount if Lane C's client files are missing. + renderPlan(sizeStr) { return renderPlan(this.tl, sizeStr); } + // A capture loop OWNS the timeline: it steps the clock, swaps cameras and hides + // the chrome. Anything the user does to the scene bar or the lanes while it runs + // either corrupts the film or is lost. `_busy` is the one gate. + _busy() { return !!(this._rendering || this._filmBusy); } + // The one choke point BOTH render paths go through, so it is where the model + // learns a capture is running. Disabling DOM controls can only reach `$bar`; + // Lane A's DIRECT panel and the dock live outside it and mutate the scene by + // dispatching window events straight at timeline.js (`scenegod:direct`, + // `scenegod:clipdrop`, `scenegod:capturekey`) — see Timeline._locked. + _barBusySet(b) { + if (this.tl) this.tl.locked = b; + if (!this.$bar) return; + for (const el of this.$bar.querySelectorAll('button,select,input')) el.disabled = b; + } + async render() { + const no = renderRefusal({ rendering: this._rendering, filmBusy: this._filmBusy }); + if (no) { this._toast(no); return; } // never two capture loops on one timeline + let plan; + try { plan = this.renderPlan(this.$rsize.value); } + catch (e) { this._toast('render: ' + this._errText(e)); return; } + // audio is validated by the server at POST /render/{id}/end — i.e. after + // every frame has been uploaded. Refuse the whole render up front instead. + const bad = audioProblems(plan.audio); + if (bad.length) { this._toast('render: ' + bad.join(' · ')); return; } + // claim the timeline BEFORE the first await — two fast clicks would otherwise + // both get through the gate while the existence check is in flight — and + // release it in a FINALLY that covers every exit from here on, so no early + // return can leave the app permanently "rendering" with a dead scene bar. + const saved = this.tl.time; // read before the claim (both sync — nothing can interleave) + const label = this.$renderBtn.textContent; // …so a throw here can't strand the gate + this._rendering = true; + this._barBusySet(true); // Save / New… / Load / ▶ all go dead too + try { + const missing = await this._audioMissing(plan.audio); + if (missing.length) { + this._toast(`render: audio not found under assets — ${missing.join(', ')}`); return; + } + this.tl.pause(); this._audioStop(); // wall-clock playback vs frame-stepping + this.$rstat.textContent = ''; + const R = await import('./render.js'); + const url = await R.finalRender(this.stage, this.tl, { + ...plan, + onProgress: (n, total) => { this.$renderBtn.textContent = `rendering ${n}/${total}`; }, + }); + const a = document.createElement('a'); // relative — never /render/… + a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = url; + this.$rstat.textContent = ''; this.$rstat.appendChild(a); + this._toast('⏺ rendered — ' + url); + } catch (e) { this._toast('render failed — ' + this._errText(e)); } + finally { + this._rendering = false; + this._barBusySet(false); + this.$renderBtn.textContent = label; + this.tl.seek(saved); // put the playhead back where it was + this.draw(); + } + } + + // Existence half of the audio pre-flight: one ONE-BYTE ranged GET per distinct + // path against the same `assets/file` route the server resolves against + // (relative URL — prod serves under /scenegod/). + // REVIEW-FIX: this used to send HEAD, which made the whole check INERT. + // FastAPI's APIRoute registers only the methods you decorate — unlike + // starlette's own Route it does NOT add HEAD to a GET route — so + // `@app.get("/assets/file")` answers every HEAD with 405, and `status === 404` + // could never be true no matter how many audio files had been moved. Measured + // against a real uvicorn on this repo: HEAD → 405 `allow: GET`; GET → 404; + // GET with `Range: bytes=0-0` → 206, content-length 1. So: a real GET (the + // method the route answers) that asks for one byte, and the body is cancelled + // rather than streamed — a 40MB stem must not be downloaded to prove it exists. + // Fails OPEN: a network wobble must not block a render, it just means the + // server gets the last word as before. + async _audioMissing(audio) { + const paths = [...new Set(((audio || []).map((a) => a && a.path).filter(Boolean)))]; + const out = []; + for (const p of paths) { + try { + const r = await fetch('assets/file?path=' + encodeURIComponent(p), { headers: { Range: 'bytes=0-0' } }); + if (r.body && r.body.cancel) r.body.cancel().catch(() => {}); // don't download the file to check it exists + if (r.status === 404) out.push(p); + } catch { /* offline → let the server decide */ } + } + return out; + } + // ---- persistence (Lane C endpoints; localStorage fallback) ---- + // A save that fails must SAY SO. It used to catch every non-ok response into + // localStorage with no toast and no console line, so a 422 from validate_scene + // was indistinguishable from success — and because load() prefers the server + // copy, the next Load silently restored the last good version and ate + // everything since. Only a thrown fetch (genuinely offline) falls back now. async save() { const name = (this.$name.value || 'untitled').trim(); this.tl.scene.name = name; + this.tl.syncFromStage(); // gizmo moves + inspector edits → the model const json = this.tl.toJSON(); + const ups = uploadEntities(json); // PLAN §4.1 `source.type:"upload"`: warn about dropped files + if (ups.length && !confirm(`${ups.length} dropped file${ups.length === 1 ? '' : 's'} (${ups.join(', ')}) ` + + 'can only live in this browser session and will not be in the saved scene. Save anyway?')) return; + let res; try { - const res = await fetch(`scenes/${encodeURIComponent(name)}`, { + res = await fetch(`scenes/${encodeURIComponent(name)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json), }); - if (!res.ok) throw new Error(res.status); } catch { - localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C reachable + localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // real network failure + this._toast(saveMessage({ ok: false, name })); + return; } + if (!res.ok) { + const body = await res.text().catch(() => ''); + const why = body.trim() ? [this._errText(body)] : null; // unwraps {errors:[…]} / {detail} + const msg = saveMessage({ ok: false, status: res.status, errors: why, name }); + console.error('[scenegod:save]', res.status, body); + this._toast(msg); + return; + } + // The server SLUGIFIES: `My Scene` is written as my-scene.json and the Load + // picker lists `my-scene`. It hands the real stem back in the response body + // and this used to throw it away, so the toast named a file that does not + // exist. Take the server's answer and put it in the field + the scene, so the + // next save writes the same file and the bar matches the picker. + const stem = await res.json().then((b) => (b && typeof b.name === 'string' && b.name) || name).catch(() => name); + if (stem !== name) { + this.tl.scene.name = stem; if (this.$name) this.$name.value = stem; + // REVIEW-FIX: the server stores the BODY verbatim (`scene_save` writes what + // you POST), so my-scene.json still carried `"name": "My Scene"` and the + // next Load put the un-slugged name straight back into the bar while the + // Load picker went on listing `my-scene` — the same disagreement, restored + // on every load. Write the server's own stem back and re-POST once. The + // stem is idempotent (slugify(slug) === slug), so this converges in one + // extra request and only when the name actually needed slugging. Best + // effort: the scene IS saved either way, the toast already names the file. + json.name = stem; + try { + await fetch(`scenes/${encodeURIComponent(stem)}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json), + }); + } catch { /* the first write landed; the stored name stays stale until the next save */ } + } + this._toast(saveMessage({ ok: true, name: stem, count: (json.entities || []).length })); + } + + // Load used to slugify nothing, list nothing, and do literally nothing on a + // wrong name — while a RIGHT name wiped the stage and every unsaved key with no + // warning, unlike the two other clobbering actions in the same bar. Now: a + // picker of what the server actually has, and the same confirm as New…. + async _pickScene() { + let list = []; + try { const r = await fetch('scenes'); if (r.ok) list = await r.json(); } catch { /* offline */ } + const menu = document.createElement('div'); menu.className = 'tl-menu'; + if (!Array.isArray(list) || !list.length) menu.innerHTML = '
no saved scenes on the server
'; + for (const s of (list || [])) { + const item = document.createElement('div'); item.className = 'item'; + const when = s.mtime ? new Date(s.mtime * 1000).toLocaleString() : ''; + item.textContent = `${s.name}${s.duration ? ' · ' + (+s.duration).toFixed(1) + 's' : ''}${when ? ' · ' + when : ''}`; + item.onclick = () => { menu.remove(); this.load(s.name); }; + menu.appendChild(item); + } + document.body.appendChild(menu); + const rect = this.el.querySelector('[data-act="load"]').getBoundingClientRect(); + menu.style.top = (rect.bottom + 2) + 'px'; + menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 6)) + 'px'; + const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } }; + setTimeout(() => document.addEventListener('mousedown', close), 0); } async load(name) { name = (name || '').trim(); if (!name) return; let json = null; try { const res = await fetch(`scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ } if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); } - if (!json) return; - await this.tl.applyScene(json); // stage.applyState → tl.load (onLoad → rows+bar) → preload + if (!json) { this._toast(`no scene called "${name}" on the server or in this browser`); return; } + if (this.tl.hasContent() && !confirm('Replace the current scene? Unsaved work is lost.')) return; + try { await this.tl.applyScene(json); } // stage.applyState → tl.load (onLoad → rows+bar) → preload + catch (e) { + // one missing clip used to reject out of load() uncaught, which also killed + // _decodeAll() — the audio row went dead and every later clip did nothing. + this._toast(`load "${name}" failed — ` + this._errText(e)); + } this._decodeAll(); // decode audio for lane widths + playback + this._syncRows(); this._refreshBar(); this.draw(); } } diff --git a/scripts/test_render_client.mjs b/scripts/test_render_client.mjs new file mode 100644 index 0000000..658b920 --- /dev/null +++ b/scripts/test_render_client.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node +// test_render_client.mjs — headless checks on web/render.js (Lane C). +// +// node scripts/test_render_client.mjs +// +// No deps, no browser, no three: fake Stage + fake Timeline + stubbed fetch. +// Pins two bugs that shipped in real mp4s: +// 1. the capture loop never called stage.pause(), so Stage._chrome() (SYNC7) +// never fired and every frame carried the gizmo/grid/camera cones; +// 2. withRenderSize restored the viewport from DEVICE pixels through setSize, +// which multiplies by the pixel ratio again — the buffer grew by dpr^1 per +// render on a retina Mac. +// Plus the supersample contract (item C3): render at 2x, deliver at 1x. +import { finalRender, captureFrames, withRenderSize, ssFactor } from '../scenegod/web/render.js'; +import { readFile } from 'node:fs/promises'; + +let failures = 0; +const ok = (cond, msg) => { if (!cond) { failures++; console.error('FAIL:', msg); } }; +// An in-flight frame POST that rejects with nobody awaiting it is a DROPPED FRAME, +// not just console noise (see group 5b) — so the whole file runs under a sentinel. +const unhandled = []; +process.on('unhandledRejection', e => unhandled.push((e && e.message) || String(e))); +const eq = (a, b, msg) => ok(JSON.stringify(a) === JSON.stringify(b), + `${msg}\n got ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`); + +// --- fakes ------------------------------------------------------------------- +function fakeStage(calls, { width = 2560, height = 1440, clientWidth = 1280, clientHeight = 720, + dpr = 2 } = {}) { + const domElement = { width, height, clientWidth, clientHeight, + toBlob: cb => cb(new Blob(['png'])) }; + return { + renderer: { + domElement, + setSize(...a) { calls.push(['setSize', ...a]); }, + setPixelRatio(r) { calls.push(['dpr', r]); }, + getPixelRatio: () => dpr, + }, + pause() { calls.push(['pause']); }, + resume() { calls.push(['resume']); }, + renderActiveCamera() { calls.push(['render']); }, + }; +} + +const fakeTimeline = (duration = 1, fps = 30) => ({ + time: 0, duration, fps, + step(f) { this.time = f / fps; }, +}); + +// every render/* endpoint the client touches, answered instantly +function stubFetch(log) { + globalThis.fetch = async (url, init = {}) => { + log.push(String(url)); + const body = String(url).endsWith('/begin') ? { renderId: 'r1' } + : String(url).endsWith('/status') ? { state: 'done' } + : { ok: true }; + return { ok: true, status: 200, json: async () => body, text: async () => '' }; + }; +} + +// --- 1. pause -> render xN -> resume, and the CSS-size restore --------------- +{ + const calls = [], urls = []; + stubFetch(urls); + const stage = fakeStage(calls); + await finalRender(stage, fakeTimeline(1, 30), { width: 1280, height: 720, fps: 30 }); + + const kinds = calls.map(c => c[0]); + const pauseAt = kinds.indexOf('pause'), resumeAt = kinds.indexOf('resume'); + ok(pauseAt !== -1, 'stage.pause() was never called — editor chrome (gizmo/grid/cones) bakes into the mp4'); + ok(resumeAt !== -1, 'stage.resume() was never called — the editor stays frozen after a render'); + ok(kinds.filter(k => k === 'pause').length === 1, 'pause called more than once'); + ok(kinds.filter(k => k === 'resume').length === 1, 'resume called more than once'); + const renders = kinds.reduce((a, k, i) => (k === 'render' ? [...a, i] : a), []); + eq(renders.length, 30, '30 frames (duration 1s @30fps) should be rendered'); + ok(renders.every(i => i > pauseAt && i < resumeAt), + 'every renderActiveCamera must sit BETWEEN pause and resume'); + // frames posted 0..29, then end, then status + eq(urls.filter(u => u.includes('/frame/')).length, 30, 'one POST per frame'); + ok(urls.some(u => u.endsWith('render/r1/end')), 'endRender was not posted'); + + const sizes = calls.filter(c => c[0] === 'setSize'); + eq(sizes[sizes.length - 1].slice(1, 3), [1280, 720], + 'viewport must be restored in CSS px (1280x720), not device px (2560x1440) — ' + + 'setSize multiplies by the pixel ratio, so device px grows the buffer every render'); + eq(calls.filter(c => c[0] === 'dpr').map(c => c[1]), [1, 2], 'pixel ratio: forced to 1, then restored'); +} + +// --- 2. supersample: render 2x, deliver 1x ----------------------------------- +{ + const calls = [], urls = []; + stubFetch(urls); + await finalRender(fakeStage(calls), fakeTimeline(0.1, 30), { width: 1280, height: 720, fps: 30, ss: 2 }); + const sizes = calls.filter(c => c[0] === 'setSize'); + eq(sizes[0].slice(1, 3), [2560, 1440], 'ss:2 must render a 1280x720 deliverable at 2560x1440'); + eq(sizes[sizes.length - 1].slice(1, 3), [1280, 720], 'restore is still the CSS size'); +} + +// --- 3. ss forced to 1 ABOVE 1080p (1080p itself still supersamples) --------- +{ + const calls = [], urls = []; + stubFetch(urls); + await finalRender(fakeStage(calls), fakeTimeline(0.1, 30), { width: 2560, height: 1440, fps: 30 }); + const sizes = calls.filter(c => c[0] === 'setSize'); + eq(sizes[0].slice(1, 3), [2560, 1440], + 'above 1080p ss is forced to 1 — a 2x buffer there blows past texture/body limits'); + eq([ssFactor(1280, 720), ssFactor(1920, 1080), ssFactor(1920, 1081), ssFactor(3840, 2160), + ssFactor(1280, 720, 1)], [2, 2, 1, 1, 1], 'ssFactor thresholds'); + // the default deliverable IS 1080p, so it must be the case that gets the 2x buffer + const c2 = []; + await finalRender(fakeStage(c2), fakeTimeline(0.1, 30), { width: 1920, height: 1080, fps: 30 }); + eq(c2.filter(c => c[0] === 'setSize')[0].slice(1, 3), [3840, 2160], + 'a 1920x1080 deliverable renders at 3840x2160 (this is why the frame cap is 40MB)'); +} + +// --- 4. the deliverable size is what render/begin declares ------------------- +{ + const bodies = []; + globalThis.fetch = async (url, init = {}) => { + if (String(url).endsWith('/begin')) bodies.push(JSON.parse(init.body)); + const body = String(url).endsWith('/begin') ? { renderId: 'r1' } + : String(url).endsWith('/status') ? { state: 'done' } : { ok: true }; + return { ok: true, status: 200, json: async () => body, text: async () => '' }; + }; + await finalRender(fakeStage([]), fakeTimeline(0.1, 30), { width: 1280, height: 720, fps: 30 }); + eq([bodies[0].width, bodies[0].height], [1280, 720], + 'render/begin must declare the OUTPUT size (meta.json describes the deliverable), not the 2x buffer'); +} + +// --- 5. pause/resume balanced when a frame POST throws ------------------------ +{ + const calls = []; + globalThis.fetch = async () => ({ ok: false, status: 500, json: async () => ({}), text: async () => '' }); + const stage = fakeStage(calls); + let threw = false; + try { + await captureFrames(stage, fakeTimeline(1, 30), { renderId: 'r1', from: 0, to: 30 }); + } catch { threw = true; } + ok(threw, 'a failing frame POST must reject'); + ok(calls.filter(c => c[0] === 'resume').length === 1, + 'resume() must run even when a frame POST throws (try/finally)'); +} + +// --- 5b. ONE failed frame POST must not be swallowed ------------------------- +// The .finally() pulls each POST out of `inflight` as it settles, so a rejection +// nobody happened to be awaiting used to disappear: measured 29/30 frames stored +// and captureFrames RESOLVING. ffmpeg's %06d demuxer stops at the gap -> a short +// mp4 with state=done, and no error anywhere. +{ + const stored = []; + globalThis.fetch = async url => { + const n = Number(String(url).split('/').pop()); + if (String(url).includes('/frame/') && n === 3) + return { ok: false, status: 500, json: async () => ({}), text: async () => '' }; + if (String(url).includes('/frame/')) stored.push(n); + return { ok: true, status: 200, json: async () => ({}), text: async () => '' }; + }; + let msg = null; + try { + await captureFrames(fakeStage([]), fakeTimeline(1, 30), { renderId: 'r1', from: 0, to: 30 }); + } catch (e) { msg = e.message; } + ok(msg !== null, 'a single failed frame POST must reject the capture — it used to resolve, ' + + `storing ${stored.length}/30 frames and encoding a truncated mp4`); + ok(/frame 3/.test(msg || ''), `the rejection must name the frame that failed (got ${msg})`); +} + +// --- 6. withRenderSize restores from device px when the panel is hidden ------- +{ + const calls = []; + const stage = fakeStage(calls, { clientWidth: 0, clientHeight: 0, width: 2560, height: 1440, dpr: 2 }); + await withRenderSize(stage, 640, 360, async () => {}); + const sizes = calls.filter(c => c[0] === 'setSize'); + eq(sizes[sizes.length - 1].slice(1, 3), [1280, 720], + 'hidden panel (clientWidth 0): fall back to device px / dpr, still CSS px'); +} + +// --- 7. the render must not leave a GHOST GIZMO in the viewport -------------- +// Stage._chrome(true) re-shows chrome after every paused frame by FORCING +// visible = true on the TransformControls object — even when select() had +// detached it (nothing selected, or a frustum-fitted plate). This fake mirrors +// stage.js exactly: attach/detach drive _tc.visible (three r160 semantics), and +// _render brackets each paused draw with _chrome(false)/_chrome(true). +function chromeStage(calls, selected = null, live = ['e1']) { + const s = fakeStage(calls); + s._selected = selected; + s.getEntity = id => (live.includes(id) ? { id } : null); + s._tc = { visible: selected != null, object: selected ?? undefined }; + s.select = id => { // stage.js select(): attach or detach + s._selected = id; + if (id == null) { s._tc.visible = false; s._tc.object = undefined; } + else { s._tc.visible = true; s._tc.object = id; } + calls.push(['select', id]); + }; + s._chrome = v => { s._tc.visible = v; }; + s.pause = () => { calls.push(['pause']); s._paused = true; }; + s.resume = () => { calls.push(['resume']); s._paused = false; }; + s.renderActiveCamera = () => { // stage.js _render() + calls.push(['render']); + if (s._paused) { s._chrome(false); /* renderer.render */ s._chrome(true); } + }; + return s; +} +{ + const calls = [], urls = []; + stubFetch(urls); + const nothingSelected = chromeStage(calls, null); + await finalRender(nothingSelected, fakeTimeline(0.1, 30), { width: 640, height: 360, fps: 30 }); + ok(nothingSelected._tc.visible === false, + 'nothing selected: the transform gizmo must still be hidden after a render — ' + + '_chrome(true) forces it visible, leaving a ghost gizmo floating in the viewport'); + ok(nothingSelected._selected === null, 'the selection itself must not change across a render'); + + const calls2 = []; + const oneSelected = chromeStage(calls2, 'e1'); + await finalRender(oneSelected, fakeTimeline(0.1, 30), { width: 640, height: 360, fps: 30 }); + ok(oneSelected._tc.visible === true && oneSelected._tc.object === 'e1', + 'something selected: its gizmo must come BACK after the render'); + ok(oneSelected._selected === 'e1', 'selection preserved'); + + // film.js reloads a whole scene per shot: the remembered id can be gone by the + // time we put the gizmo back, and re-attaching to a dead entity is worse than + // no gizmo at all. + const calls3 = []; + const gone = chromeStage(calls3, 'e9', ['e1']); // e9 was in the PREVIOUS shot + await finalRender(gone, fakeTimeline(0.1, 30), { width: 640, height: 360, fps: 30 }); + ok(gone._tc.visible === false && gone._selected === null, + 'a selection whose entity no longer exists must end up deselected, not re-attached'); +} + +// --- 8. pause/resume are a REQUIREMENT, not a silent option ------------------ +{ + const noPause = fakeStage([]); + delete noPause.pause; + let threw = false; + try { await captureFrames(noPause, fakeTimeline(0.1, 30), { renderId: 'r1', from: 0, to: 1 }); } + catch (e) { threw = /pause/.test(e.message); } + ok(threw, 'a Stage without pause() must THROW — an optional-chained call would silently ' + + 'bake the gizmo/grid/cones back into every mp4 with all tests green'); +} + +// --- 9. ...and the REAL Stage still has them -------------------------------- +// pause/resume are not in PLAN §4.2's Stage contract, so nothing else pins them: +// this reads Lane A's live file so a rename/refactor fails HERE, not in an mp4. +{ + const src = await readFile(new URL('../scenegod/web/stage.js', import.meta.url), 'utf8'); + ok(/\bpause\s*\(\s*\)\s*\{[^}]*_paused\s*=\s*true/.test(src), + 'web/stage.js must define pause() setting _paused = true (render.js depends on it)'); + ok(/\bresume\s*\(\s*\)\s*\{[^}]*_paused\s*=\s*false/.test(src), + 'web/stage.js must define resume() clearing _paused'); + ok(/if\s*\(\s*this\._paused\s*\)\s*this\._chrome\(/.test(src), + 'web/stage.js _render must gate editor chrome on _paused — that is what pause() buys'); + ok(/this\._selected\s*=/.test(src), + 'web/stage.js must keep the _selected field render.js re-asserts the gizmo from'); +} + +// --- 10. nothing anywhere above left a rejection on the floor --------------- +await new Promise(r => setTimeout(r, 50)); // let node flag any straggler +ok(unhandled.length === 0, `unhandled promise rejection(s): ${unhandled.join(', ')} — ` + + 'in the render loop that means a frame POST failed and nobody noticed'); + +console.log(failures ? `FAILED: ${failures} assertion(s)` : 'OK: render.js client checks passed'); +process.exit(failures ? 1 : 0); diff --git a/scripts/test_server.py b/scripts/test_server.py index 570dbe9..4fbede6 100644 --- a/scripts/test_server.py +++ b/scripts/test_server.py @@ -56,6 +56,31 @@ def probe_dur(f): "-of", "default=nw=1:nokey=1", str(f)], text=True).strip()) +def probe_v(f, field): + """One ffprobe field off the VIDEO stream (width/height/color_primaries/...).""" + return subprocess.check_output( + ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", + f"stream={field}", "-of", "default=nw=1:nokey=1", str(f)], text=True).strip() + + +def probe_rgb(f): + """Top-left pixel of frame 0, decoded through the file's OWN colour tags. + + This is the only assertion that can catch a mislabelled encode: ffprobe will + happily report color_primaries=bt709 on a file whose pixels were converted + with bt601, and every player then shifts hue+contrast off what the canvas + drew. Decode it back to RGB and compare with what went in.""" + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(f), "-frames:v", "1", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], + capture_output=True).stdout + assert len(raw) >= 3, f"could not decode a frame from {f}" + return tuple(raw[0:3]) + + +def near(got, want, tol=6): + return all(abs(a - b) <= tol for a, b in zip(got, want)) + + def seg_db(f, ss, dur): """mean volume (dB) of one window of a media file — digital silence reads about -91 dB, so this is how a test asks 'is the audio actually HERE?'. @@ -83,6 +108,14 @@ def main(): for d in ("animations", "props", "audio"): (assets / d).mkdir() (assets / "props" / "box.glb").write_bytes(b"b") # no image sidecar + # a SYMLINKED bank inside the asset root — the documented way John gets his + # MeshGod gallery in (`ln -s ~/MESHGOD/assets3d/gallery assets/props/...`). + # Both halves used to be broken: os.walk skipped it, and safe_under's + # .resolve() put it outside ASSETS so every fetch 400'd. + bank = tmp / "extbank" + bank.mkdir() + (bank / "lamp.glb").write_bytes(b"lampbytes") + os.symlink(bank, assets / "props" / "gallery") renders, sequences, films = tmp / "renders", tmp / "sequences", tmp / "films" env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes), @@ -128,6 +161,54 @@ def main(): assert st >= 400, f"traversal {bad!r} not rejected: {st}" n += 1 + # --- safe_under is lexical now: traversal still dead, symlinks live --- + # (unit, in-process — the guard is the one function every path endpoint uses) + sys.path.insert(0, str(ROOT)) + os.environ.setdefault("SCENEGOD_ASSETS", str(assets)) + from scenegod.server import safe_under, video_args, vf_args # noqa: E402 + from fastapi import HTTPException # noqa: E402 + for bad in ("../../etc/passwd", "a/../../x", "/etc/passwd", "..", "..\\..\\x", + "a/b/../../../c"): + try: + safe_under(Path("/root"), bad) + raise AssertionError(f"safe_under accepted {bad!r}") + except HTTPException as e: + assert e.status_code == 400, (bad, e.status_code) + assert safe_under(Path("/root"), "characters/test/x.glb") == Path("/root/characters/test/x.glb") + assert safe_under(Path("/root"), "a/./b") == Path("/root/a/b") + assert safe_under(Path("/root"), "") == Path("/root") # empty relpath = the root itself + # integration: the symlinked bank is listed AND streamable + props = {g["path"] for g in get_json("/assets/tree")["props"]} + assert "props/gallery/lamp.glb" in props, f"symlinked bank not scanned: {props}" + st, b = req("GET", "/assets/file?path=props/gallery/lamp.glb") + assert st == 200 and b == b"lampbytes", (st, b) # was a bare 400 "bad path" + n += 1 + + # --- encoder options: colour-tagged, faststart, one lossy generation --- + for crf, args in ((18, video_args()), (14, video_args(14))): + assert args[args.index("-crf") + 1] == str(crf), args + for flag, val in (("-colorspace", "bt709"), ("-color_primaries", "bt709"), + ("-color_trc", "bt709"), ("-color_range", "tv")): + assert args[args.index(flag) + 1] == val, args + assert "+faststart" in args, args + for input_only in ("-i", "-framerate", "-ss", "-t", "-f", "-y"): + assert input_only not in args, f"{input_only} is an input flag: {args}" + assert vf_args({"width": 320, "height": 180})[1].startswith( + "scale=320:180:flags=lanczos:out_color_matrix=bt709,"), vf_args({"width": 320, "height": 180}) + for m in ({"width": 320, "height": 180}, {}, {"width": 8, "height": 8}): + chain = vf_args(m)[1] + assert "setparams" in chain, (m, chain) # the TAG rides on every encode... + # ...and so does the CONVERSION. setparams alone writes bt709 into the VUI + # while swscale quietly does RGB->YUV with bt601 (see server.py CONVERT): + # every mp4 mislabelled, saturated colour visibly off. Never one without + # the other — this is asserted per-pixel further down, in the render tests. + assert "out_color_matrix=bt709" in chain, (m, chain) + assert chain.index("out_color_matrix") < chain.index("setparams"), \ + f"the conversion must happen BEFORE the tag: {chain}" + # no size declared = no RESIZE (the scale is there for the matrix only) + assert vf_args({})[1].startswith("scale=out_color_matrix=bt709,"), vf_args({}) + n += 1 + # --- scene round-trip --- scene = {"version": 1, "name": "s1", "fps": 30, "duration": 5.0, "entities": [{"id": "e1", "kind": "camera", "label": "cam", @@ -242,11 +323,113 @@ def main(): ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "default=nw=1:nokey=1", out], text=True).strip() assert "audio" in codecs, f"no audio stream muxed: {codecs!r}" + # colour tags: an untagged yuv420p mp4 makes QuickTime/Safari/NLEs + # GUESS the primaries and shift hue+contrast off what the canvas drew + assert probe_v(out, "color_primaries") == "bt709", probe_v(out, "color_primaries") + assert probe_v(out, "color_transfer") == "bt709", probe_v(out, "color_transfer") # bad audio path rejected before encode assert req("POST", f"/render/{rid}/end", json.dumps({"audio": [{"path": "audio/nope.wav"}]}))[0] == 400 n += 1 + # --- supersample: frames arrive 2x, ffmpeg delivers the declared size --- + big = tmp / "bigframes" + big.mkdir() + ff("-f", "lavfi", "-i", "testsrc=size=640x360:rate=30", "-frames:v", "10", + str(big / "%06d.png")) + r3 = json.loads(req("POST", "/render/begin", json.dumps( + {"name": "ss", "fps": 30, "width": 320, "height": 180}))[1])["renderId"] + for i, png in enumerate(sorted(big.glob("*.png"))): + assert req("POST", f"/render/{r3}/frame/{i}", png.read_bytes())[0] == 200 + assert req("POST", f"/render/{r3}/end", json.dumps({}))[0] == 200 + for _ in range(150): + s3 = json.loads(req("GET", f"/render/{r3}/status")[1])["state"] + if s3 in ("done", "error"): + break + time.sleep(0.2) + assert s3 == "done", (s3, json.loads(req("GET", f"/render/{r3}/status")[1])["log"][-600:]) + ssout = renders / r3 / "out.mp4" + assert probe_v(ssout, "width") == "320" and probe_v(ssout, "height") == "180", \ + (probe_v(ssout, "width"), probe_v(ssout, "height")) + assert probe_v(ssout, "color_primaries") == "bt709" + # x264 stamps its settings in an SEI — a deliverable is crf 18 + assert b"crf=18.0" in ssout.read_bytes(), "deliverable should encode at crf 18" + # and -vf coexists with the audio -filter_complex (different streams) + r4 = json.loads(req("POST", "/render/begin", json.dumps( + {"name": "ssa", "fps": 30, "width": 320, "height": 180}))[1])["renderId"] + for i, png in enumerate(sorted(big.glob("*.png"))): + assert req("POST", f"/render/{r4}/frame/{i}", png.read_bytes())[0] == 200 + assert req("POST", f"/render/{r4}/end", json.dumps( + {"audio": [{"path": "audio/test/tone.wav", "start": 0, "gain": 1.0}], + "intermediate": True}))[0] == 200 + for _ in range(150): + s4 = json.loads(req("GET", f"/render/{r4}/status")[1])["state"] + if s4 in ("done", "error"): + break + time.sleep(0.2) + assert s4 == "done", (s4, json.loads(req("GET", f"/render/{r4}/status")[1])["log"][-600:]) + sa = renders / r4 / "out.mp4" + assert probe_v(sa, "width") == "320", probe_v(sa, "width") + assert seg_db(sa, 0.05, 0.2) > -35, "audio lost when -vf and -filter_complex coexist" + # film SHOTS are intermediates: finer crf so the concat's re-encode is + # the only generation that meaningfully quantises (film.js sets this) + assert b"crf=14.0" in sa.read_bytes(), "intermediate shot should encode at crf 14" + n += 1 + + # --- COLOUR: the pixels must agree with the tags ---------------- + # The tags-only version of this test passed while every render was + # converted RGB->YUV with bt601 under a bt709 label: pure green stored + # Y=145 (bt709 green is 173) and decoded back as (0,216,0). Assert the + # ROUND TRIP, per pixel, on both filter paths — that is the only thing + # that can tell a correct encode from a confidently mislabelled one. + def render_pngs(begin_body, src, count=5, end_body="{}"): + r = json.loads(req("POST", "/render/begin", json.dumps(begin_body))[1])["renderId"] + for i in range(count): + assert req("POST", f"/render/{r}/frame/{i}", Path(src).read_bytes())[0] == 200 + assert req("POST", f"/render/{r}/end", end_body)[0] == 200 + for _ in range(150): + stt = json.loads(req("GET", f"/render/{r}/status")[1]) + if stt["state"] in ("done", "error"): + break + time.sleep(0.2) + return r, stt + + for name, want in (("0x00FF00", (0, 255, 0)), # the loudest case: bt601 green is 24 IRE low + ("0xE62878", (230, 40, 120)), # neon magenta — this app's actual material + ("0x3C78DC", (60, 120, 220))): # sky blue + flat = tmp / f"flat{name}.png" + ff("-f", "lavfi", "-i", f"color=c={name}:s=256x144", "-frames:v", "1", str(flat)) + # sized path (the supersample downscale: 256x144 frames -> 128x72) + rc, stt = render_pngs({"name": "col", "fps": 30, "width": 128, "height": 72}, flat) + assert stt["state"] == "done", stt + got = probe_rgb(renders / rc / "out.mp4") + assert near(got, want), (f"{name} scaled render decodes as {got}, expected ~{want} " + "— pixels converted with the wrong matrix for the tag") + # no-size path (begin body without width/height: setparams only + iw:ih scale) + rd, stt = render_pngs({"name": "col2", "fps": 30}, flat) + assert stt["state"] == "done", stt + got = probe_rgb(renders / rd / "out.mp4") + assert near(got, want), f"{name} unscaled render decodes as {got}, expected ~{want}" + # ...and that path's scale really is matrix-only: no resize to any default + assert probe_v(renders / rd / "out.mp4", "width") == "256", \ + "a begin body without width/height must not resize the frames" + n += 1 + + # --- a capture SMALLER than the declared size is an ERROR ------- + # (it used to be silently UPSCALED into a soft mp4 with state=done) + small = tmp / "small.png" + ff("-f", "lavfi", "-i", "color=c=red:s=64x36", "-frames:v", "1", str(small)) + rs, stt = render_pngs({"name": "undersized", "fps": 30, "width": 320, "height": 180}, small) + assert stt["state"] == "error", f"undersized frames encoded anyway: {stt}" + assert "64x36" in stt["log"] and "320x180" in stt["log"], stt["log"] + assert not (renders / rs / "out.mp4").exists(), "no mp4 should be written" + # bigger is normal (that IS the supersample) and equal is fine + for w, h in ((128, 72), (256, 144)): + _, stt = render_pngs({"name": "okay", "fps": 30, "width": w, "height": h}, + tmp / "flat0x00FF00.png") + assert stt["state"] == "done", (w, h, stt) + n += 1 + # --- M9-C1: audio in/out honoured at the mux ------------------- # trim.wav = 1s of silence, then 1s of 880Hz. Trimming to the tone # and starting at 0.5s must put SOUND at 0.5-1.5s and nothing else; @@ -416,6 +599,19 @@ def main(): dur = float(probe(out)) assert abs(dur - 5.0) < 0.15, f"cut film {dur}s, expected ~5s (2+3)" assert probe(out, "stream=width,height").split() == ["64", "64"], probe(out, "stream=width,height") + # the film is the file anyone actually watches — it must be tagged too + assert probe_v(out, "color_primaries") == "bt709", probe_v(out, "color_primaries") + assert probe_v(out, "color_transfer") == "bt709", probe_v(out, "color_transfer") + # ...and TAGGED IS NOT CONVERTED. A film assembled from shots that are + # not themselves bt709 (an older build's mp4 still sitting in renders/, + # anything hand-dropped) used to inherit their bt601 pixels under the + # film's bt709 tag: green came out (0,216,0). These fixtures are exactly + # that case — untagged lavfi mp4s — so this discriminates. + filmc = make_film("film-colour", {"version": 1, "shots": shots}) + outc = finish_film(filmc["filmId"], [fake_shot_render(2.0, color="0x00FF00"), + fake_shot_render(3.0, color="0x00FF00")]) + gotc = probe_rgb(outc) + assert near(gotc, (0, 255, 0)), f"film decodes green as {gotc}, expected ~(0,255,0)" n += 1 # dissolve variant: sum MINUS the transition, plus a film-level audio bed