M12: look, render button, save integrity — 3 workflow rounds, 22 agents
FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual quality, discoverability): - ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and a warm key rendered neutral. Single biggest look win in the app. - Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates that no longer double as the shadow catcher (dedicated ShadowMaterial). - A Render button in the scene bar. finalRender had ZERO callers - the app's deliverable was console-only. - Save actually saves the stage you built (gizmo moves, inspector edits), and Save/Load stop lying about what happened. - 2x supersample + lanczos downscale; correct bt709 tagging and faststart. - Frame guide, camera-from-view, dock counts, delete confirmations. DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first): - BLOCKER: save wrote the playhead pose over the authored rest transform of keyed entities, so every save produced a different file. - renders were converted with the bt601 matrix while tagged bt709. - deleting an unkeyed camera stranded its cut -> scene 422s forever. - corner plates showed one photo at two exposures across the fold. - exposure was advertised as keyable but nothing keyed it, so a second lighting preset permanently poisoned the first. - the audio pre-flight could never fire (FastAPI does not route HEAD -> 405). - the two render buttons were not mutually exclusive. - frame guide letterboxed a narrow viewport that the render does not crop. ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and skipped by cutCameraAt, rather than trying to keep every undo history clean - deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry can resurrect a cut for a dead camera. Verified against the real validator. Also replaced a VACUOUS test that drained a stack whose only entry was a seeded no-op; timeline_test.mjs now replays both real histories and discriminates. Verified: 4 JS suites + 23 server groups + render client green; blocker repro now preserves the authored rest at every playhead position; save round-trips 200 with zero dangling cuts; zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5baebb410a
commit
22c8a9a324
@ -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.
|
||||
|
||||
439
logs/laneA.md
439
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 <tab> 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).
|
||||
|
||||
371
logs/laneB.md
371
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 `<a target=_blank rel=noopener>` 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 — <server's own errors>` 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.
|
||||
|
||||
212
logs/laneC.md
212
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,<setparams>`, always (near-noop when sizes already match; also
|
||||
catches a client/server size mismatch). No scale when begin declared no size — the pre-existing
|
||||
`{name,fps}`-only begin body must not get upscaled to the 1280x720 default.
|
||||
- `-vf` sits before the audio `-filter_complex`; they coexist because the video map is a plain
|
||||
input (`0:v`), not a complex-graph label. **Verified with a real mux, not by reasoning** — the
|
||||
test renders 640x360 frames with a tone and asserts both 320x180 output AND that the audio is
|
||||
still there (-6dB at 0.05-0.25s).
|
||||
- `server.py:506` frame cap 25MB -> **40MB** with the reason in a comment. Every other
|
||||
`capped_body` limit untouched.
|
||||
|
||||
**C4 — symlinked asset banks (the documented way to get banks in) stop 400-ing.**
|
||||
- `server.py:95 safe_under` is now LEXICAL: normpath (backslashes -> /), reject absolute /
|
||||
`..` / `../…`, return `root / rel` UNRESOLVED. `.resolve()` follows symlinks, so
|
||||
`ln -s ~/MESHGOD/assets3d/gallery assets/props/gallery` listed in `/assets/tree` and then 400'd
|
||||
"bad path" on every fetch (dock: `failed: Error: asset fetch 400`) — characters, backdrops,
|
||||
clips, audio, /beats, /rhubarb, all of it. Traversal is still caught before touching the disk
|
||||
(normpath collapses `a/../../x` to `../x`). The docstring says all of this so a future
|
||||
ship-check pass doesn't "fix" it back, and notes the accepted symlink-loop caveat.
|
||||
- `server.py:178` `os.walk(base, followlinks=True)` so a bank symlinked one level deeper is
|
||||
scanned at all.
|
||||
- Side effect worth knowing: the lexical guard also rejects `..\..\x`, which the old
|
||||
resolve()-based guard ACCEPTED on posix (backslash isn't a separator there, so it was just a
|
||||
weird filename inside the root). Harmless either way; strictly safer now.
|
||||
|
||||
**TESTS — all four proved to DISCRIMINATE by putting the old code back:**
|
||||
- `scripts/test_render_client.mjs` (NEW, plain node, no deps, no browser, no three): fake Stage /
|
||||
Timeline / fetch. Old render.js -> **9 failures** (pause/resume never called, renders outside the
|
||||
pause window, restore at 2560x1440 instead of 1280x720, hidden-panel fallback). Also asserts:
|
||||
30 frames for 1s@30fps, one POST per frame, `render/begin` declares 1280x720 while setSize gets
|
||||
2560x1440, ss forced to 1 above 1080p, ssFactor thresholds, and that `resume()` still runs when
|
||||
a frame POST throws.
|
||||
- `scripts/test_server.py:145` safe_under unit (6 traversal rejects, 3 accepts incl. empty=root)
|
||||
+ integration (symlinked bank in `/assets/tree` and streamable, exact bytes). Old code:
|
||||
followlinks off -> `symlinked bank not scanned`; old guard -> `(400, b'{"detail":"bad path"}')`.
|
||||
- `:172` video_args/vf_args unit: bt709 on all four fields, `+faststart`, crf 14 vs 18, no
|
||||
input-only flag (`-i/-framerate/-ss/-t/-f/-y`) in the output list, setparams on every encode.
|
||||
- `:298`/`:308` render smoke: colour tags on out.mp4; 640x360 frames -> 320x180 output; the
|
||||
`-vf` + `-filter_complex` coexistence with audio; crf SEI check. `:513` the FILM is bt709 too.
|
||||
Old encoders -> `AssertionError: unknown` (primaries) and `('640','360')` (scale).
|
||||
DECISIONS:
|
||||
- **1080p still supersamples.** The item's prose said force ss=1 when `w>1920||h>1080`; its
|
||||
sample test line said a 1920x1080 render should get ss=1. Those contradict, and the prose wins:
|
||||
the default deliverable IS 1080p, so ss=1 there would make the whole feature a no-op for the
|
||||
common case — and the same item's 40MB-cap rationale explicitly assumes "a 1080p deliverable
|
||||
posts 3840x2160 PNGs". So >1080p output = no supersample; 1080p and below = 2x. Written into
|
||||
the test as an explicit assert with that reasoning.
|
||||
- `crf` is not a free client knob: `/render/end` takes a BOOLEAN `intermediate`, and the server
|
||||
picks 14 or 18. A client can't ask for crf 51.
|
||||
- `vf_args` skips the scale when begin declared no size rather than defaulting to 1280x720 —
|
||||
the existing test posts 64x64 frames with a `{name,fps}`-only begin body and must not be
|
||||
upscaled. Tags still ride on that path.
|
||||
BLOCKED/REQUESTS:
|
||||
- @Lane B (tlui.js, FYI not a blocker): `renderPlan()`'s object is spread into `finalRender`, so
|
||||
adding `ss: 1` to it is all that's needed if you ever want a "fast draft" toggle — the option
|
||||
already exists client-side. Also note a 1080p render now uploads 3840x2160 PNGs: expect it to
|
||||
take roughly 2-4x longer than before and consider saying so on the button.
|
||||
- @orchestrator — nothing needed for prod: no new env, no new dep, ffmpeg in the image already
|
||||
has `setparams` (filter exists since 4.1). The :8020 uvicorn is still not `--reload`, so
|
||||
restart it before browser-checking any of this. My tests ran on ports 8097-8104 only.
|
||||
NEXT: unchanged backlog (per-scene audio auto-carried into a film bed; more xfade transition
|
||||
types; MediaRecorder draft path still unverified in a real Chrome window; `?from=&to=` windowing
|
||||
on /beats). New candidate: `draftRecord` should pause the stage too — it deliberately does NOT
|
||||
(it records the LIVE canvas, so chrome is arguably wanted in a draft), but if John disagrees it
|
||||
is a two-line change.
|
||||
|
||||
## 2026-07-28 session 10 — adversarial-review fixes (colour was a LIE, gizmo ghost, dropped frames)
|
||||
**23 test groups green** (was 21) + 4 new client groups. `.venv/bin/python scripts/test_server.py`
|
||||
-> `OK: 23 test groups passed`; `node scripts/test_render_client.mjs` -> `OK: render.js client
|
||||
checks passed`; `node --check` clean on render.js, film.js, test_render_client.mjs.
|
||||
|
||||
**MAJOR 1 — session 9's colour work TAGGED the file bt709 and CONVERTED the pixels bt601.**
|
||||
Reproduced with the shipped arg list before touching anything: pure green PNG in, first luma
|
||||
sample out = **145** (bt601 green; bt709 is 173), file decodes through its own bt709 tag as
|
||||
(0,216,0). Neon magenta (230,40,120) -> (244,61,119). Root cause is NOT "we forgot a flag": a
|
||||
`setparams` filter PINS ITS OUTPUT LINK to bt709, which leaves the scale filter's output link
|
||||
unspecified, so swscale's RGB->YUV falls back to bt601 — with **no** setparams at all the graph
|
||||
negotiates bt709 off `-colorspace` and gets it right. i.e. session 9's tag *caused* the mislabel
|
||||
it was meant to cure. Fix = state the conversion on the converting filter: `server.py:441
|
||||
CONVERT = "out_color_matrix=bt709"`, used by `vf_args` (`:463`, both the sized and the no-size
|
||||
chain) and by `_concat_film`'s per-shot scale (`:850`). Measured after: Y=173, round-trip
|
||||
(0,255,1) / (230,38,119).
|
||||
- The reviewer's sub-claim that the NO-SIZE path was equally broken did **not** reproduce:
|
||||
setparams-only was already correct (the auto-inserted scaler sits AFTER setparams and honours
|
||||
the tag). Made it explicit anyway — `scale=out_color_matrix=bt709` keeps iw:ih — so correctness
|
||||
stops depending on where ffmpeg chooses to insert its scaler. Test pins that it does not resize.
|
||||
- Regression: `test_server.py` `probe_rgb()` decodes frame 0 back to RGB and `near()` compares.
|
||||
Three colours (green / neon magenta / sky) x both filter paths, plus the FILM (its untagged
|
||||
lavfi fixtures are exactly the "old shot mp4 still in renders/" case). **Discriminates**: with
|
||||
CONVERT removed -> `0x00FF00 scaled render decodes as (0, 216, 0)`; film -> `film decodes green
|
||||
as (0, 216, 0)`. Tag asserts alone stay green through all of that — that is why they missed it.
|
||||
|
||||
**MAJOR 2 — pause() woke up dead code that left a GHOST GIZMO in the viewport.**
|
||||
`stage.js _chrome(true)` (Lane A) re-shows chrome after every paused frame by FORCING
|
||||
`_tc.visible = true`, overriding the `visible = false` that `select()`'s `detach()` just set.
|
||||
So a render started with nothing selected (or with a frustum-fitted plate selected — same detach)
|
||||
ended with an inert translate gizmo floating at the last object's position: the exact bug class
|
||||
pause() was added to kill, re-introduced by pause() itself.
|
||||
- Fix in my file: `render.js:81 restoreSelectionChrome(stage)`, called from `captureFrames`'s
|
||||
finally (`:139`). Re-runs `select()` with the id Stage already holds, so attach/detach decides
|
||||
the gizmo again. It re-selects only if `stage.getEntity(id)` still resolves — film.js swaps the
|
||||
whole scene per shot, so the remembered id is often gone and re-attaching to a dead entity
|
||||
would be worse than no gizmo.
|
||||
- Regression: client group 7 models stage.js exactly (attach/detach drive `_tc.visible`, `_render`
|
||||
brackets each paused draw with `_chrome(false)/_chrome(true)`); three cases — nothing selected,
|
||||
something selected, selection since deleted. **Discriminates**: dropping the call -> 2 FAILs.
|
||||
|
||||
**BONUS (nobody caught this one) — a failed frame POST was SILENTLY DROPPED.**
|
||||
Found while wiring the new tests: the `.finally(() => inflight.delete(p))` pulls each POST out of
|
||||
the set as it settles, so a rejection nobody happened to be awaiting vanished. Measured with only
|
||||
frame 3 failing: `captureFrames` **RESOLVED**, 29/30 frames stored, plus an unhandled rejection —
|
||||
ffmpeg's `%06d` demuxer then stops at the gap and you get a short mp4 with `state=done`. Fixed at
|
||||
`render.js:129` (`.catch` remembers the first error) + `:132`/`:136` re-throw, so it fails fast at
|
||||
the frame that broke. Regression = client group 5b + a process-wide unhandledRejection sentinel
|
||||
(group 10). **Discriminates**: old code -> 3 FAILs (resolves, no frame number, 29 unhandled).
|
||||
|
||||
**MINOR — vf_args' scale silently UPSCALED an undersized capture** (docstring claimed the
|
||||
opposite: "catches any client/server size mismatch"). Now `_encode` ffprobes frame 0 (`_png_size`,
|
||||
`server.py:466`) and errors before spawning when the frames are SMALLER than the declared
|
||||
deliverable — the lost-WebGL-context / clamped-canvas case, previously a soft mushy mp4 with
|
||||
state=done and no log line. Bigger is normal (that is the supersample); equal is fine. Docstrings
|
||||
now describe what the code does. **Discriminates**: guard disabled -> `undersized frames encoded
|
||||
anyway: {'state': 'done'...}` with 64x36 frames upscaled to 320x180.
|
||||
|
||||
**MINOR — pause/resume were optional-chained, so losing them would fail silently.** They are not
|
||||
in PLAN §4.2's Stage contract and `stage.pause?.()` no-ops on any Stage without them, which would
|
||||
put the gizmo back in every mp4 with all tests green. Now REQUIRED (`render.js:116` throws a
|
||||
message that says why), and client group 9 reads Lane A's live `web/stage.js` and fails if
|
||||
`pause()`/`resume()`/the `_paused` chrome gate/`_selected` stop existing — the first test in this
|
||||
repo that pins a cross-lane seam the PLAN doesn't cover. **Discriminates**: reverting to `?.` ->
|
||||
FAIL; and the stage.js regexes go false on a mutated copy with `pause()` renamed.
|
||||
|
||||
DECISIONS:
|
||||
- A tag is not a conversion, and in ffmpeg they FIGHT: `setparams` breaks the colourspace
|
||||
negotiation that would otherwise do the right thing. Both halves are now written next to each
|
||||
other with the measurement in the comment (`server.py:428-441`) so neither gets "cleaned up".
|
||||
- Colour assertions are per-PIXEL from here on. Session 9's tests asserted the metadata the code
|
||||
writes, which can only ever prove the code did what it says, never that the result is right.
|
||||
- `restoreSelectionChrome` reads `stage._selected` (a private field) rather than a getter, because
|
||||
there isn't one. It is guarded by `'_selected' in stage`, so a Lane A rename degrades to "no
|
||||
restore" rather than clearing John's selection — and group 9 fails loudly if that field goes.
|
||||
BLOCKED/REQUESTS:
|
||||
- @Lane A (stage.js — DO NOT let me edit it): `_chrome(visible)` forces `visible = true` on
|
||||
everything it touches, so it cannot restore state it did not save. `_chrome(true)` un-hides the
|
||||
DETACHED TransformControls (three r160: `attach()`/`detach()` drive `.visible` directly). Please
|
||||
snapshot each object's `visible` in `_chrome(false)` and restore those values in `_chrome(true)`;
|
||||
then my `restoreSelectionChrome` guard in render.js becomes belt-and-braces and can go. Same
|
||||
latent hole covers `en.helper` and anything else that is legitimately hidden at render time.
|
||||
- @orchestrator (deploy/ is not mine): prod nginx `client_max_body_size 30m`
|
||||
(deploy/nginx-scenegod.conf) is now BELOW this server's 40MB frame cap (server.py:552) and its
|
||||
comment ("render frames <=25MB + slack") is stale. Nothing hits either wall today — 4K PNG
|
||||
frames measure ~3MB typical — but the binding limit in prod is the one Lane C cannot see, and a
|
||||
413 there arrives with no message from us. Please bump to 48m and refresh the comment.
|
||||
- @orchestrator, disclosing the FULL cost of session 9's ss=2-at-1080p deviation (I logged the
|
||||
deviation but not its bill): the default ⏺ Render posts 3840x2160 PNGs, so /opt/scenegod-data/
|
||||
renders holds ~2.6x the bytes for the 48h reap window (a film keeps every shot's frames), the
|
||||
upload over the tunnel is ~2.6x, and the default render is 2-4x slower. There is no UI opt-out
|
||||
because `renderPlan()` (tlui.js, Lane B) emits no `ss` — the option exists client-side, it just
|
||||
is not wired to a control. If you want the acceptance line honoured instead (1920x1080 -> ss 1),
|
||||
it is one number in `ssFactor` and one assert in test_render_client.mjs; say the word and I will
|
||||
flip it rather than defend it.
|
||||
NEXT: unchanged backlog (per-scene audio auto-carried into a film bed; more xfade transition
|
||||
types; MediaRecorder draft path still unverified in a real Chrome window; `?from=&to=` windowing
|
||||
on /beats). New candidate off this session: `_encode` could also refuse a frame COUNT that does
|
||||
not match `duration*fps` — the same invisible-failure family as the undersized-frame guard.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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() + '<i>0</i>'; // 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() + '<i>' + ((this.tree[t] || []).length) + '</i>'; }
|
||||
const items = this.tree[this.tab] || [];
|
||||
if(!items.length){
|
||||
this.list.innerHTML = `<div class="empty">${this._offline
|
||||
? 'server offline — drag a .glb / .fbx / image onto the view'
|
||||
: 'nothing here — drop banks into assets/' + this.tab + '/'}</div>`;
|
||||
this.list.innerHTML = this._offline
|
||||
? '<div class="empty">server offline — drag a .glb / .fbx / image onto the view</div>'
|
||||
: this._loadErr
|
||||
? `<div class="empty"><b>assets/tree failed</b><br>${esc(this._loadErr)}</div>`
|
||||
// "drop banks into assets/…" was an instruction you cannot follow from a browser
|
||||
: `<div class="empty">no ${esc(this.tab)} on the server yet — drag a .glb / .fbx / image `
|
||||
+ 'from your desktop onto the stage</div>';
|
||||
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
|
||||
// "<entity> 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' ? ' · <span class="warn">upload</span>' : ''}</span>`;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 <select> exists only now, two awaited imports after the DIRECT panel
|
||||
// mounted — this is the earliest moment the ⬚ frame guide can read the delivery aspect it
|
||||
// must mask to. (Its `change` listener keeps it in step from here on.)
|
||||
direct?.syncFrameAspect?.();
|
||||
} catch(e){ console.warn('timeline not mounted:', e); }
|
||||
|
||||
window.stage = stage; window.dock = dock;
|
||||
|
||||
@ -10,6 +10,14 @@ const D2R = Math.PI / 180;
|
||||
// full-frame vertical fov (degrees) for a focal length in mm (24mm-tall sensor)
|
||||
export function fovForFocal(mm){ return 2 * Math.atan(12 / mm) / D2R; }
|
||||
|
||||
// "1920x1080" → 1.777… — the value shape of tlui's render-size selector, so the ⬚ frame guide can
|
||||
// mask to the size that will actually be encoded. null for anything that isn't a positive W×H.
|
||||
export function aspectFromSize(v){
|
||||
const m = /^\s*(\d+)\s*[x×]\s*(\d+)\s*$/.exec(String(v == null ? '' : v));
|
||||
const a = m ? +m[1] / +m[2] : NaN;
|
||||
return Number.isFinite(a) && a > 0 ? a : null;
|
||||
}
|
||||
|
||||
// XYZ euler (radians) that points a camera (looks down −Z) at `aim`, plus dutch roll.
|
||||
export function lookAtEuler(pos, aim, rollDeg = 0){
|
||||
let z = [pos[0]-aim[0], pos[1]-aim[1], pos[2]-aim[2]]; // camera +Z = away from aim
|
||||
@ -165,16 +173,17 @@ export async function applyLight(stage, name){
|
||||
if(!sun) sun = await stage.addEntity({ kind:'light', label:'sun',
|
||||
params:{ type:'key', color:L.sun.color, intensity:L.sun.intensity, castShadow:true } });
|
||||
if(!amb) amb = await stage.addEntity({ kind:'light', label:'ambient',
|
||||
params:{ type:'ambient', color:L.ambient.color, intensity:L.ambient.intensity } });
|
||||
params:{ type:'ambient', color:L.ambient.color, intensity:L.ambient.intensity, exposure:L.exposure } });
|
||||
stage.setParam(sun.id, 'color', L.sun.color);
|
||||
stage.setParam(sun.id, 'intensity', L.sun.intensity);
|
||||
stage.setParam(amb.id, 'color', L.ambient.color);
|
||||
stage.setParam(amb.id, 'intensity', L.ambient.intensity);
|
||||
stage.setParam(amb.id, 'bg', L.bg); // background rides the ambient entity → keyable + persists
|
||||
stage.setParam(amb.id, 'exposure', L.exposure); // …and so does the ACES level (applyOps keys it)
|
||||
const e = L.sun.elev*D2R, a = L.sun.azim*D2R, R = 20; // 20m boom aimed at chest height
|
||||
const pos = [R*Math.cos(e)*Math.sin(a), R*Math.sin(e), R*Math.cos(e)*Math.cos(a)];
|
||||
stage.setTransform(sun.id, { pos, rot: lookAtEuler(pos, [0, 1, 0]), scale: 1 });
|
||||
return { preset: name, sun: { ...L.sun }, ambient: { ...L.ambient }, bg: L.bg,
|
||||
return { preset: name, sun: { ...L.sun }, ambient: { ...L.ambient }, bg: L.bg, exposure: L.exposure,
|
||||
sunId: sun.id, ambientId: amb.id, sunPos: pos };
|
||||
}
|
||||
|
||||
@ -244,13 +253,37 @@ async function _camFor(stage, o){
|
||||
const _tl = () => (typeof window !== 'undefined' && window.timeline) || null;
|
||||
const _playhead = () => { const t = _tl(); return t && typeof t.time === 'number' ? t.time : 0; };
|
||||
const _sceneDur = () => { const t = _tl(); return t && typeof t.duration === 'number' ? t.duration : 0; };
|
||||
// ONE BUTTON PRESS = ONE Ctrl+Z. Lane B gives every `scenegod:direct` event its own group(), i.e.
|
||||
// its own undo entry — so an op that has to be sent as TWO events (below: the light payload Lane B
|
||||
// keys, then the ACES `exposure` its light branch does not) came apart under the first undo,
|
||||
// leaving a preset's colours at the previous preset's level: a half-applied look, which is the
|
||||
// mismatched-exposure bug wearing a different hat. `collapseUndo` is Lane B's own tool for edits
|
||||
// that span EVENTS rather than a function call (it is how tlui turns a 50-mousemove drag into one
|
||||
// entry): mark the stack before the first event, collapse after the last. No-op for 0 or 1 entries,
|
||||
// so the day Lane B keys `d.exposure` in its light branch, the second _fire and this pair of
|
||||
// helpers can be deleted with nothing else to change.
|
||||
const _undoMark = () => { const t = _tl(); return t && Array.isArray(t.undoStack) ? t.undoStack.length : -1; };
|
||||
const _undoJoin = mark => { const t = _tl();
|
||||
if(mark >= 0 && t && typeof t.collapseUndo === 'function') t.collapseUndo(mark); };
|
||||
export async function applyOps(stage, ops, selectedId = null){
|
||||
const applied = [];
|
||||
for(const o of (ops || [])){
|
||||
try {
|
||||
if(o.op === 'light'){
|
||||
const mark = _undoMark(); // …everything below is ONE undo entry (see _undoJoin)
|
||||
const v = await applyLight(stage, o.preset);
|
||||
_fire({ op:'light', ...v, entityIds:[v.sunId, v.ambientId] });
|
||||
// `exposure` is the one preset value Lane B's light branch does not key (it keys sun
|
||||
// colour/intensity + transform and ambient colour/intensity/bg). Unkeyed, a second preset
|
||||
// later in the scene left the whole EARLIER stretch rendering at the LATER preset's ACES
|
||||
// level — in the viewport and in the mp4 — because the renderer is global and nothing
|
||||
// ever put it back. The generic op shape ({entityIds, params}) is a documented branch of
|
||||
// the same handler, so this lands an ordinary `exposure` param key at the playhead with
|
||||
// no change on Lane B's side. It is a SECOND event only because one event gets one
|
||||
// branch; the undo cost that used to come with that is gone (_undoJoin).
|
||||
if(v.exposure != null)
|
||||
_fire({ op:'light', entityIds:[v.ambientId], params:{ exposure: v.exposure } });
|
||||
_undoJoin(mark);
|
||||
applied.push(v);
|
||||
} else if(o.op === 'mark'){
|
||||
const id = _resolve(stage, o.subject) ?? selectedId;
|
||||
@ -334,6 +367,34 @@ export function mountDirectPanel(stage, el){
|
||||
focal:st.focal, newCam:true }]);
|
||||
note(r.error ? String(r.error) : `+ ${stage.getEntity(r.camId)?.label || 'camera'} · ${st.shot} ${st.angle} ${st.focal}mm`);
|
||||
}, 'NEW camera on this subject — coverage; every existing camera stays put');
|
||||
// ⬚ frame guide: mask the viewport down to the delivery aspect so headroom/lead room are judged
|
||||
// in the frame that actually gets encoded. DOM overlay in the Stage — never reaches a render.
|
||||
if(stage.setFrameGuide){
|
||||
const fgb = btn(lens, '⬚ frame', () => fgb.classList.toggle('on', stage.setFrameGuide(!stage.frameGuide())),
|
||||
'mask the viewport to the delivery aspect + thirds/action-safe guides (never rendered)');
|
||||
fgb.classList.add('on'); // default ON — composing in the wrong rectangle is the whole problem
|
||||
}
|
||||
// …and that delivery aspect is whatever the render will ACTUALLY be encoded at: follow tlui's
|
||||
// render-size selector. Without this `setFrameAspect` had no caller anywhere in the repo, so the
|
||||
// guide was permanently 16:9 and would quietly lie the first time a square or scope size existed.
|
||||
// Delegated on the window because the timeline (and its selector) mounts AFTER this panel.
|
||||
// The FIRST read cannot be a timer: `select.rsize` is created by TimelineUI's constructor, which
|
||||
// index.html only reaches after awaiting two dynamic module imports — strictly later than the
|
||||
// next macrotask, so `setTimeout(readSize, 0)` always lost that race and found null. It is
|
||||
// returned instead, and index.html calls it once the timeline is up (a no-op when Lane B's
|
||||
// files are absent). Harmless while every size is 16:9; a lie the first time one isn't.
|
||||
const syncFrameAspect = () => {
|
||||
if(!stage.setFrameAspect || typeof document === 'undefined') return null;
|
||||
const el = document.querySelector('select.rsize');
|
||||
const a = el && aspectFromSize(el.value);
|
||||
if(a) stage.setFrameAspect(a);
|
||||
return a || null;
|
||||
};
|
||||
if(stage.setFrameAspect && typeof document !== 'undefined'){
|
||||
addEventListener('change', e => { const t = e.target;
|
||||
if(t && t.classList && t.classList.contains('rsize')) syncFrameAspect(); }, true);
|
||||
syncFrameAspect(); // in case the selector is somehow already there
|
||||
}
|
||||
|
||||
// MOVE: solve a start+end pair. The camera jumps to the start now; Lane B keys the end at t0+dur.
|
||||
const mv = seg('moves', 'move');
|
||||
@ -396,5 +457,5 @@ export function mountDirectPanel(stage, el){
|
||||
fetch('director').then(r => { if(r.status === 405 || r.ok) nl.style.display = ''; }).catch(() => {});
|
||||
|
||||
const info = document.createElement('span'); info.className = 'dinfo'; el.appendChild(info);
|
||||
return { state: st };
|
||||
return { state: st, syncFrameAspect };
|
||||
}
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
// (presets.js takes bboxes as plain arrays; expected values below are computed inline).
|
||||
// node scenegod/web/presets_test.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { LIGHTS, SHOTS, MOVES, FOCALS, MOVE_MAX_DUR } from './grammar.js';
|
||||
import { fovForFocal, solveShot, applyLight, solveMove, moveDuration,
|
||||
stepShot, stepFocal, applyOps } from './presets.js';
|
||||
stepShot, stepFocal, applyOps, aspectFromSize } from './presets.js';
|
||||
|
||||
const near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
||||
const R2D = 180 / Math.PI;
|
||||
@ -55,9 +56,22 @@ assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.sunId && c[2] === 'inte
|
||||
'sun intensity applied to stage');
|
||||
assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.ambientId && c[2] === 'bg' && c[3] === g.bg),
|
||||
'bg applied via ambient entity');
|
||||
// ---- per-preset ACES exposure (the renderer level that rebalances presets ~6 stops apart) ----
|
||||
for(const [k, L] of Object.entries(LIGHTS)){
|
||||
assert.ok(Number.isFinite(L.exposure), `LIGHTS.${k} has no finite exposure`);
|
||||
assert.ok(L.exposure >= 0.5 && L.exposure <= 2, `LIGHTS.${k}.exposure ${L.exposure} outside [0.5, 2]`);
|
||||
}
|
||||
// the dark looks must be lifted relative to day, or ACES just crushes them (that is the point)
|
||||
assert.ok(LIGHTS.night.exposure > LIGHTS.day.exposure, 'night is exposed up against day');
|
||||
assert.ok(LIGHTS.horror.exposure > LIGHTS.day.exposure, 'horror is exposed up against day');
|
||||
// second call reuses the same entities (no light spam)
|
||||
await applyLight(stub, 'noir');
|
||||
const before = calls.length;
|
||||
const nv = await applyLight(stub, 'noir');
|
||||
assert.equal(stub._ents.length, 2, 'presets reuse the sun/ambient pair');
|
||||
assert.equal(nv.exposure, LIGHTS.noir.exposure, 'applyLight returns the preset exposure');
|
||||
assert.ok(calls.slice(before).some(c => c[0] === 'param' && c[1] === nv.ambientId
|
||||
&& c[2] === 'exposure' && c[3] === LIGHTS.noir.exposure),
|
||||
'exposure applied via the ambient entity (like bg → keyable + persists)');
|
||||
|
||||
// ================= M11 CAMERA MOVES =================
|
||||
// Independent check that a camera state actually LOOKS AT a point: three's Euler('XYZ') builds
|
||||
@ -267,6 +281,113 @@ sstub.setActiveCamera(null); // director orbit cam live → fall back to the o
|
||||
const [s5] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'MS' }]);
|
||||
assert.equal(s5.camId, camA, 'no active camera → the original shotcam, as before');
|
||||
|
||||
// ---- LIGHT ops: `exposure` must land as a KEY, not just on the renderer -----------------------
|
||||
// The ACES level is a RENDERER global. Applying it per preset without keying it meant a second
|
||||
// preset later in the scene rendered the whole EARLIER stretch at the LATER preset's level — in
|
||||
// the viewport and in the mp4 — and saved it that way. Lane B's light branch keys sun
|
||||
// colour/intensity/transform + ambient colour/intensity/bg and nothing else, so the exposure key
|
||||
// rides the GENERIC op shape of the same handler: {op:'light', entityIds, params} with no
|
||||
// sunId/sun (timeline.js applyDirect: `if (d.sunId != null && d.sun) … else` = the generic branch).
|
||||
fired.length = 0;
|
||||
const [lv] = await applyOps(sstub, [{ op:'light', preset:'night' }]);
|
||||
assert.ok(!lv.error, 'light op applied: ' + lv.error);
|
||||
const lightEvents = fired.filter(d => d.op === 'light');
|
||||
const mainEv = lightEvents.find(d => d.sunId != null && d.sun);
|
||||
assert.ok(mainEv, 'the preset still emits Lane B\'s light payload (sun + ambient + bg)');
|
||||
const expEv = lightEvents.find(d => d.sunId == null && !d.sun);
|
||||
assert.ok(expEv, 'a preset ALSO emits a generic-shape op so `exposure` gets keyed');
|
||||
assert.deepEqual(expEv.entityIds, [lv.ambientId], 'keyed on the ambient entity, like bg');
|
||||
assert.equal(expEv.params.exposure, LIGHTS.night.exposure, 'carrying the preset\'s exposure verbatim');
|
||||
assert.deepEqual(Object.keys(expEv.params), ['exposure'],
|
||||
'and ONLY exposure — the generic branch applies every param to every id in entityIds');
|
||||
|
||||
// …and end-to-end through Lane B's real Timeline when it is present (the page tolerates its
|
||||
// absence, so this suite does too): two presets at different times, scrub back, correct level.
|
||||
let Timeline = null;
|
||||
try { ({ Timeline } = await import('./timeline.js')); } catch { /* Lane B absent — payload check above stands */ }
|
||||
if(Timeline){
|
||||
const chg = [];
|
||||
const lstub = {
|
||||
_ents: [], _n: 0, exposure: 1, bg: null,
|
||||
entities(){ return this._ents; },
|
||||
getEntity(id){ return this._ents.find(e => e.id === id); },
|
||||
async addEntity(d){ const en = { id:'L' + (++this._n), kind:d.kind, label:d.label, params:{ ...d.params } };
|
||||
this._ents.push(en); for(const cb of chg) cb(en); return en; },
|
||||
entityTransform(id){ return this.getEntity(id)?._tf || { pos:[0,0,0], rot:[0,0,0], scale:1 }; },
|
||||
setTransform(id, t){ const e = this.getEntity(id); if(e) e._tf = { pos:[0,0,0], rot:[0,0,0], scale:1, ...t }; },
|
||||
setParam(id, k, v){ const e = this.getEntity(id); if(!e) return; e.params[k] = v;
|
||||
if(k === 'exposure') this.exposure = v; if(k === 'bg') this.bg = v; },
|
||||
entityBBox(){ return null; }, setActiveCamera(){}, activeCamera(){ return null; },
|
||||
viewAspect(){ return 16/9; }, onChange(cb){ chg.push(cb); },
|
||||
entityVideo(){ return null; }, entityMixer(){ return null; },
|
||||
};
|
||||
const prevWindow = globalThis.window, prevDispatch = globalThis.dispatchEvent;
|
||||
globalThis.window = { addEventListener(){} };
|
||||
const tl = new Timeline(lstub);
|
||||
globalThis.window.timeline = tl;
|
||||
globalThis.dispatchEvent = ev => { tl.applyDirect(ev.detail); return true; };
|
||||
tl.seek(0); await applyOps(lstub, [{ op:'light', preset:'day' }]);
|
||||
tl.seek(5); await applyOps(lstub, [{ op:'light', preset:'night' }]);
|
||||
const ambRec = tl.scene.entities.find(x => x.params && x.params.type === 'ambient');
|
||||
const expKeys = (ambRec.tracks.params || []).filter(k => k.key === 'exposure');
|
||||
assert.equal(expKeys.length, 2, 'one exposure key per preset press (t=0 and t=5)');
|
||||
tl.seek(0);
|
||||
assert.equal(lstub.exposure, LIGHTS.day.exposure,
|
||||
'scrubbing back to the day preset restores DAY\'s exposure, not night\'s');
|
||||
assert.equal(lstub.bg, LIGHTS.day.bg, 'bg was always fine — exposure now behaves the same way');
|
||||
tl.seek(5);
|
||||
assert.equal(lstub.exposure, LIGHTS.night.exposure, 'and the night stretch is still night');
|
||||
|
||||
// ONE PRESS = ONE Ctrl+Z. A preset has to go out as TWO `scenegod:direct` events (Lane B's light
|
||||
// payload, then the generic op that carries `exposure` — one event gets one branch), and Lane B
|
||||
// gives every event its own group(), i.e. its own undo entry. So the first Ctrl+Z popped the
|
||||
// exposure half ONLY: the preset's colours stayed keyed while the whole stretch rendered at the
|
||||
// PREVIOUS preset's ACES level — under-exposed in the viewport and in the mp4, saved that way,
|
||||
// with nothing in the UI to show it and no second undo that means "finish the job".
|
||||
const sunRec = tl.scene.entities.find(x => x.params && x.params.type === 'key');
|
||||
const keysAt = (rec, t) => (rec.tracks.params || []).filter(k => Math.abs(k.t - t) < 1e-9)
|
||||
.map(k => k.key).sort();
|
||||
const depth0 = tl.undoStack.length;
|
||||
tl.seek(9); await applyOps(lstub, [{ op:'light', preset:'noir' }]);
|
||||
assert.equal(tl.undoStack.length, depth0 + 1, 'a light preset costs exactly ONE undo entry');
|
||||
assert.deepEqual(keysAt(ambRec, 9), ['bg', 'color', 'exposure', 'intensity'],
|
||||
'the press keyed the whole ambient look at the playhead');
|
||||
assert.deepEqual(keysAt(sunRec, 9), ['color', 'intensity'], '…and the sun with it');
|
||||
tl.undo();
|
||||
assert.deepEqual(keysAt(ambRec, 9), [], 'ONE undo takes the WHOLE look back — never just exposure');
|
||||
assert.deepEqual(keysAt(sunRec, 9), [], '…including the half that came in on the other event');
|
||||
assert.equal(tl.undoStack.length, depth0, '…and gives the stack back exactly as it found it');
|
||||
tl.seek(5);
|
||||
assert.equal(lstub.exposure, LIGHTS.night.exposure, 'the presses before it are untouched');
|
||||
assert.equal(lstub.bg, LIGHTS.night.bg, 'colour and level still agree with each other');
|
||||
globalThis.window = prevWindow; globalThis.dispatchEvent = prevDispatch;
|
||||
}
|
||||
|
||||
// ---- ⬚ frame guide follows the DELIVERY size ---------------------------------------------------
|
||||
// stage.setFrameAspect had no caller anywhere in the repo: the guide was hardwired to 16:9 and
|
||||
// would have masked the wrong rectangle the moment a square/scope render size existed.
|
||||
assert.ok(near(aspectFromSize('1920x1080'), 16/9, 1e-12), '1920x1080 → 16:9');
|
||||
assert.equal(aspectFromSize('1080x1080'), 1, 'a square delivery is a square gate');
|
||||
assert.ok(near(aspectFromSize('2048×858'), 2048/858, 1e-12), 'the × character parses too');
|
||||
for(const bad of ['', null, undefined, 'nonsense', '1920x0', '0x1080', '1920-1080'])
|
||||
assert.equal(aspectFromSize(bad), null, `garbage (${bad}) → null, never a NaN aspect`);
|
||||
const presetsSrc = fs.readFileSync(new URL('./presets.js', import.meta.url), 'utf8');
|
||||
assert.match(presetsSrc, /stage\.setFrameAspect\(/, 'presets.js actually calls setFrameAspect');
|
||||
assert.match(presetsSrc, /select\.rsize/, '…off the render-size selector, not a hardcoded aspect');
|
||||
// The first read cannot be scheduled on a timer: `select.rsize` is built by TimelineUI's
|
||||
// constructor, which index.html reaches only after awaiting TWO dynamic imports — strictly later
|
||||
// than the next macrotask, so a setTimeout(…, 0) always found null and the guide only ever
|
||||
// followed the selector from its first `change` onward. mountDirectPanel returns the read; the
|
||||
// page performs it the moment the timeline is up.
|
||||
assert.doesNotMatch(presetsSrc.replace(/^\s*\/\/.*$/gm, ''), // …the CODE; the comment says why
|
||||
/setTimeout\(\s*(readSize|syncFrameAspect)/,
|
||||
'the first frame-aspect read is not racing two module imports on a 0ms timer');
|
||||
assert.match(presetsSrc, /syncFrameAspect\s*\}/, 'mountDirectPanel hands the read back to its mounter');
|
||||
const pageSrc = fs.readFileSync(new URL('./index.html', import.meta.url), 'utf8');
|
||||
assert.match(pageSrc, /syncFrameAspect\?\.\(\)/, 'index.html performs that read');
|
||||
assert.ok(pageSrc.indexOf('syncFrameAspect?.()') > pageSrc.indexOf('new TimelineUI('),
|
||||
'…AFTER TimelineUI has created the selector, which is the whole point');
|
||||
|
||||
console.log('presets_test: ALL PASS (' + Object.keys(MOVES).length + ' moves; push in '
|
||||
+ dist(push.from.pos, push.aim).toFixed(2) + 'm → ' + dist(push.to.pos, push.aim).toFixed(2)
|
||||
+ 'm, orbit ±' + MOVES.orbit_R.degrees + '°, zoom ' + FOCALS[FOCALS.indexOf(35)] + '→'
|
||||
|
||||
@ -32,9 +32,16 @@ export function draftRecord(stage, timeline, { fps = 30, mimeType = 'video/webm'
|
||||
// --- render-session primitives (shared with film.js) -------------------------
|
||||
|
||||
// Render at exactly width x height, then put the viewport back however fn ends.
|
||||
// canvas.width/height are DEVICE pixels (CSS size x devicePixelRatio) but setSize
|
||||
// multiplies by the ratio AGAIN — restoring those grew the viewport buffer by dpr
|
||||
// per render on a retina Mac (4x the pixels after one film, 16x after two, until a
|
||||
// window resize). Snapshot the CSS size instead; clientWidth is 0 while the panel
|
||||
// is hidden, so fall back to device/dpr. (No three import here — header rule.)
|
||||
export async function withRenderSize(stage, width, height, fn) {
|
||||
const canvas = stage.renderer.domElement;
|
||||
const prev = { w: canvas.width, h: canvas.height, dpr: stage.renderer.getPixelRatio() };
|
||||
const dpr = stage.renderer.getPixelRatio() || 1;
|
||||
const prev = { w: canvas.clientWidth || Math.round(canvas.width / dpr),
|
||||
h: canvas.clientHeight || Math.round(canvas.height / dpr), dpr };
|
||||
stage.renderer.setPixelRatio(1);
|
||||
stage.renderer.setSize(width, height, false); // false = don't touch CSS size
|
||||
try { return await fn(); }
|
||||
@ -44,35 +51,100 @@ export async function withRenderSize(stage, width, height, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Supersample factor: render 2x the deliverable and let ffmpeg's lanczos resolve
|
||||
// it (server vf_args). MSAA only cleans geometry edges — shading and texture
|
||||
// aliasing (fences, foliage, hair cards, specular sparkle) crawl frame to frame
|
||||
// without this. Forced off above 1080p: a 4K x2 buffer blows past sane texture
|
||||
// limits and pushes PNG frames at the body cap.
|
||||
export function ssFactor(width, height, ss = 2) {
|
||||
return (width > 1920 || height > 1080) ? 1 : Math.max(1, ss | 0);
|
||||
}
|
||||
|
||||
export async function beginRender({ name = 'scene', fps = 30, width = 1920, height = 1080 } = {}) {
|
||||
return postJSON('render/begin', { name, fps, width, height });
|
||||
}
|
||||
|
||||
// Stage._chrome(true) re-shows editor chrome after every PAUSED frame by forcing
|
||||
// visible = true on the TransformControls object — including when detach() had
|
||||
// just set it to false. three r160's TransformControls is a scene-added Object3D
|
||||
// (index.html pins three@0.160.0; stage.js does scene.add(tc)) whose attach/detach
|
||||
// drive `.visible` directly, and stage.select() detaches whenever nothing is
|
||||
// selected OR a frustum-fitted plate is. So a render started from those states used
|
||||
// to END with a ghost gizmo floating at the last object's position, inert until the
|
||||
// next click — the exact class of bug pause() was added to kill.
|
||||
// Re-running select() with the id Stage already holds re-runs attach/detach and puts
|
||||
// the gizmo back where the selection says it should be. Cheap and idempotent.
|
||||
// The real fix is stage.js saving/restoring visibility instead of forcing it (Lane A
|
||||
// owns that file — logged as a REQUEST); this is the render-side guarantee that our
|
||||
// pause() cannot leave the editor damaged. Skipped when the Stage has no `_selected`
|
||||
// field: unknown shape, don't guess a selection.
|
||||
function restoreSelectionChrome(stage) {
|
||||
if (typeof stage.select !== 'function' || !('_selected' in stage)) return;
|
||||
const id = stage._selected ?? null;
|
||||
// film.js swaps the whole scene between shots, so the remembered id can be gone
|
||||
// by now — re-selecting it would point Stage at an entity that no longer exists.
|
||||
const alive = id != null && (typeof stage.getEntity !== 'function' || !!stage.getEntity(id));
|
||||
stage.select(alive ? id : null);
|
||||
}
|
||||
|
||||
// Step [from, to) on the timeline and post those frames as 0..n-1 of renderId.
|
||||
// Deterministic: timeline.step() is seek+evaluate, never wall-clock.
|
||||
// stage.pause() is the ONLY thing that sets Stage._paused, and _render hides editor
|
||||
// chrome (gizmo arrows, the 60x60 grid across the backdrop, the other cameras' cyan
|
||||
// cones) only while paused — SYNC7's _chrome() was dead code because nobody pulled
|
||||
// this trigger, so every frame of every mp4 carried them. It also stops the rAF
|
||||
// director loop competing for the GPU at render size. Idempotent (_paused is a plain
|
||||
// boolean), so film.js calling this once per shot is fine; try/finally so a failed
|
||||
// frame POST can never leave the editor frozen.
|
||||
// pause/resume are REQUIRED, not optional-chained: the picture is wrong without them
|
||||
// and a `?.` no-op would put the gizmo back in every mp4 with every test still green.
|
||||
// They are not in PLAN §4.2's Stage list, so scripts/test_render_client.mjs reads the
|
||||
// real web/stage.js and fails if either one stops existing.
|
||||
export async function captureFrames(stage, timeline, { renderId, from = 0, to, onProgress } = {}) {
|
||||
const canvas = stage.renderer.domElement;
|
||||
const total = Math.max(0, to - from);
|
||||
const inflight = new Set();
|
||||
for (let f = from; f < to; f++) {
|
||||
timeline.step(f);
|
||||
if (stage.syncVideos) await stage.syncVideos(timeline.time); // M6 video plates: seek + wait 'seeked'
|
||||
stage.renderActiveCamera(null); // draw active cam (or director cam) to main canvas
|
||||
const blob = await new Promise(res => canvas.toBlob(res, 'image/png'));
|
||||
while (inflight.size >= 4) await Promise.race(inflight); // backpressure: <=4 in flight
|
||||
const n = f - from;
|
||||
const p = fetch(`render/${renderId}/frame/${n}`, { method: 'POST', body: blob })
|
||||
.then(r => { if (!r.ok) throw new Error(`frame ${n}: ${r.status}`); })
|
||||
.finally(() => inflight.delete(p));
|
||||
inflight.add(p);
|
||||
onProgress?.(n + 1, total);
|
||||
// A POST that fails while nobody happens to be awaiting it used to VANISH: the
|
||||
// .finally() pulls it out of `inflight`, so neither the race nor the final
|
||||
// Promise.all ever sees the rejection. Measured: with only frame 3 failing,
|
||||
// captureFrames RESOLVED with 29/30 frames stored (plus an unhandled rejection),
|
||||
// ffmpeg's %06d demuxer then stops dead at the gap and you get a short mp4 with
|
||||
// state=done. Catch every rejection into `failure` and re-throw it — the render
|
||||
// fails loudly, at the frame that broke.
|
||||
let failure = null;
|
||||
if (typeof stage.pause !== 'function' || typeof stage.resume !== 'function')
|
||||
throw new Error('render: Stage must implement pause()/resume() — without them the gizmo, ' +
|
||||
'grid and camera cones bake into every frame');
|
||||
stage.pause();
|
||||
try {
|
||||
for (let f = from; f < to; f++) {
|
||||
timeline.step(f);
|
||||
if (stage.syncVideos) await stage.syncVideos(timeline.time); // M6 video plates: seek + wait 'seeked'
|
||||
stage.renderActiveCamera(null); // draw active cam (or director cam) to main canvas
|
||||
const blob = await new Promise(res => canvas.toBlob(res, 'image/png'));
|
||||
while (inflight.size >= 4) await Promise.race(inflight); // backpressure: <=4 in flight
|
||||
const n = f - from;
|
||||
const p = fetch(`render/${renderId}/frame/${n}`, { method: 'POST', body: blob })
|
||||
.then(r => { if (!r.ok) throw new Error(`frame ${n}: ${r.status}`); })
|
||||
.catch(e => { failure = failure || e; }) // remembered, so it can't be lost...
|
||||
.finally(() => inflight.delete(p));
|
||||
inflight.add(p);
|
||||
if (failure) throw failure; // ...and fail fast, not 200 frames later
|
||||
onProgress?.(n + 1, total);
|
||||
}
|
||||
await Promise.all(inflight);
|
||||
if (failure) throw failure;
|
||||
} finally {
|
||||
stage.resume(); // resume() swallows the paused clock gap: mixers don't jump
|
||||
restoreSelectionChrome(stage); // _chrome(true) forced the detached gizmo visible
|
||||
}
|
||||
await Promise.all(inflight);
|
||||
return total;
|
||||
}
|
||||
|
||||
export async function endRender(renderId, audio = []) {
|
||||
return postJSON(`render/${renderId}/end`, { audio }); // scene audio[] muxed by ffmpeg
|
||||
// extra: {intermediate:true} from film.js — a shot mp4 is re-encoded by the concat,
|
||||
// so the server gives it a finer crf (one lossy generation that matters, not two).
|
||||
export async function endRender(renderId, audio = [], extra = {}) {
|
||||
return postJSON(`render/${renderId}/end`, { audio, ...extra }); // scene audio[] muxed by ffmpeg
|
||||
}
|
||||
|
||||
export async function pollRender(renderId, every = 500) {
|
||||
@ -86,7 +158,12 @@ export async function pollRender(renderId, every = 500) {
|
||||
// --- final: step frame-by-frame (deterministic), upload PNGs, ffmpeg encode ---
|
||||
export async function finalRender(stage, timeline, opts = {}) {
|
||||
const { fps = 30, width = 1920, height = 1080, name = 'scene', audio = [], onProgress } = opts;
|
||||
return withRenderSize(stage, width, height, async () => {
|
||||
// render big, deliver at width x height: begin/meta.json still describes the
|
||||
// DELIVERABLE, and the server downscales the frames (vf_args). A uniform
|
||||
// scale doesn't change aspect, and stage._render only refits backdrops when the
|
||||
// aspect changes — framing is untouched.
|
||||
const ss = ssFactor(width, height, opts.ss);
|
||||
return withRenderSize(stage, width * ss, height * ss, async () => {
|
||||
const total = Math.max(1, Math.round(timeline.duration * fps));
|
||||
const { renderId } = await beginRender({ name, fps, width, height });
|
||||
await captureFrames(stage, timeline, { renderId, from: 0, to: total, onProgress });
|
||||
|
||||
@ -64,6 +64,97 @@ export function fitScale(fovDeg, aspect, dist, planeW, planeH){
|
||||
return { fh, fw, s: Math.max(fw / planeW, fh / planeH) * BLEED };
|
||||
}
|
||||
|
||||
// ACES exposure lives on the RENDERER, so a keyed lerp through 0 would black the whole film —
|
||||
// clamp it. 0.05–4 is ±~4.3 stops around 1.0, more than any preset asks for.
|
||||
export const EXPOSURE_RANGE = [0.05, 4];
|
||||
export const clampExposure = v =>
|
||||
Math.min(EXPOSURE_RANGE[1], Math.max(EXPOSURE_RANGE[0], Number.isFinite(+v) ? +v : 1));
|
||||
|
||||
// The frame's ACES level belongs to the scene's ambient light (same deal as `bg`), so it is a
|
||||
// property of WHAT IS ON STAGE — never of whichever light happened to be built or deleted last.
|
||||
// It used to be written straight from _buildLight/removeEntity: pressing `night` (1.6) and then
|
||||
// `+ ambient` in the dock dropped the whole image to 1.0 with no visible cause (the new entity
|
||||
// carries no `exposure`), and deleting any ambient reset the frame even when another one was
|
||||
// still holding 1.6. Recompute from the entity list instead: the last ambient that actually
|
||||
// states an exposure wins, and 1.0 only when nothing states one.
|
||||
export function sceneExposure(entities){
|
||||
let v = null;
|
||||
for(const en of (entities || []))
|
||||
if(en && en.kind === 'light' && en.params && en.params.type === 'ambient' && en.params.exposure != null)
|
||||
v = en.params.exposure;
|
||||
return clampExposure(v == null ? 1 : v);
|
||||
}
|
||||
|
||||
// Which light edit actually MOVES the shadow box. Colour/intensity/bg/fog/exposure change how the
|
||||
// frame LOOKS, not where the box goes — and Timeline._evalParams re-applies every keyed param on
|
||||
// every tick, so treating any light key as dirty ran a Box3.setFromObject sweep over every
|
||||
// character on every frame: exactly the per-frame cost the `ponytail:` note in setTransform
|
||||
// claimed to be avoiding.
|
||||
export function shadowAffectingParam(key){ return key === 'castShadow' || key === 'type'; }
|
||||
// …and a transform is only dirty when it actually MOVED. The timeline re-applies the same pose
|
||||
// every tick, so a single keyed sun would otherwise refit the box 30×/second for nothing.
|
||||
export function samePos(a, b, eps = 1e-6){
|
||||
if(!a || !b) return false;
|
||||
for(let i = 0; i < 3; i++) if(!(Math.abs(a[i] - b[i]) < eps)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shadow-box fit. A directional light's shadow camera is an ORTHO box centred on the light→target
|
||||
// axis, and every key light here aims at the stage ORIGIN — enforced, not assumed: _buildLight
|
||||
// parents each light's target to the SCENE at (0,0,0), never to the light's own wrapper, so
|
||||
// gizmo-translating a light can't walk the box off the stage. The half-extent is therefore
|
||||
// measured from the origin, not from the entities' own centre (a lone character standing 8m
|
||||
// off-axis needs a box that reaches him, and half his own diagonal does not). Union the bboxes
|
||||
// WITH the origin, take half the diagonal, add margin for the ground the shadows land on.
|
||||
// three's default is ±5.
|
||||
export function shadowHalfExtent(bboxes, margin = 6){
|
||||
const mn = [0, 0, 0], mx = [0, 0, 0]; // seeded with the origin = the axis the box sits on
|
||||
let seen = false;
|
||||
for(const b of (bboxes || [])){
|
||||
if(!b || !b.min || !b.max) continue;
|
||||
seen = true;
|
||||
for(let i = 0; i < 3; i++){
|
||||
if(Number.isFinite(b.min[i])) mn[i] = Math.min(mn[i], b.min[i]);
|
||||
if(Number.isFinite(b.max[i])) mx[i] = Math.max(mx[i], b.max[i]);
|
||||
}
|
||||
}
|
||||
if(!seen) return 4; // bare floor — nothing to cast, nothing to fit
|
||||
return Math.max(4, Math.hypot(mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]) / 2 + margin);
|
||||
}
|
||||
|
||||
// One clamp shared by syncVideos (the CAPTURED frame) and Timeline._evalVideo (the viewport), so
|
||||
// what you scrub is what you render. Wrapping instead of clamping made a plate with videoStart:2
|
||||
// show frame 0 live and the 8.0s frame in the mp4.
|
||||
export function videoTimeAt(t, start, dur){
|
||||
return (Number.isFinite(dur) && dur > 0) ? Math.max(0, t - (start || 0)) % dur : 0;
|
||||
}
|
||||
|
||||
// Frame guide bar thicknesses (px) that mask a viewport down to the delivery aspect.
|
||||
// A camera's fov in three is VERTICAL, and rendering at another size only rewrites cam.aspect
|
||||
// (_render) — so the encoded frame ALWAYS shows exactly the vertical extent the viewport shows,
|
||||
// and differs only in WIDTH:
|
||||
// viewport wider than delivery → the render is narrower ⇒ mask left/right (pillarbox).
|
||||
// viewport narrower → the render is WIDER ⇒ there is nothing to mask; the viewport
|
||||
// is showing a crop of the frame, and `overscan` says by how
|
||||
// much (delivery ÷ viewport aspect) so the UI can disclose it.
|
||||
// Top/bottom bars are therefore ALWAYS zero. Letterboxing a tall viewport (what this used to do)
|
||||
// masked content that ends up in the mp4 and implied side content that does not exist.
|
||||
// `side` is the SIGNED viewport-px inset of the encoded frame's side edges — the same number as
|
||||
// left/right when the viewport is wider, NEGATIVE when it is narrower (the frame runs off-screen).
|
||||
// It is what positions the gate rectangle, and the gate is what the thirds and the action-safe box
|
||||
// are drawn as percentages OF: with the gate clamped to the visible area, a tall viewport drew the
|
||||
// left third ~6% of the frame width away from where it actually falls, which is the one thing a
|
||||
// director composes with. Pure geometry so node can assert it; the guide itself is DOM, never
|
||||
// WebGL (see _layoutGuide).
|
||||
export function guideBars(viewW, viewH, aspect){
|
||||
const z = { top:0, bottom:0, left:0, right:0, overscan:1, side:0 };
|
||||
if(!(viewW > 0 && viewH > 0 && aspect > 0)) return z; // hidden panel → no divide by zero
|
||||
const va = viewW / viewH;
|
||||
const side = (viewW - viewH * aspect) / 2; // frame is always viewH tall (vertical fov)
|
||||
if(va > aspect) return { ...z, left:side, right:side, side };
|
||||
return { ...z, overscan: aspect / va, side };
|
||||
}
|
||||
|
||||
export class Stage {
|
||||
constructor(viewEl){
|
||||
this.view = viewEl;
|
||||
@ -79,7 +170,12 @@ export class Stage {
|
||||
const r = this.renderer = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
|
||||
r.setPixelRatio(devicePixelRatio);
|
||||
r.shadowMap.enabled = true;
|
||||
view.appendChild(r.domElement);
|
||||
r.shadowMap.type = THREE.PCFSoftShadowMap; // free: the hard-edged default read as aliasing
|
||||
// grammar.js runs physical sun intensities of 1.2–3.4; with NoToneMapping every lit face
|
||||
// clipped to 255,255,255 and a warm key went neutral white. ACES + a per-preset exposure
|
||||
// (the ambient entity's `exposure` param) is the single biggest look win in the app.
|
||||
r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.0;
|
||||
this.view.appendChild(r.domElement);
|
||||
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
|
||||
|
||||
// PiP overlay — the active camera's view (bottom-right). CSS sizes it (~24% w); the buffer
|
||||
@ -89,7 +185,19 @@ export class Stage {
|
||||
pip.title = 'click to swap with the main view';
|
||||
this._pipSwap = false;
|
||||
pip.addEventListener('click', () => { this._pipSwap = !this._pipSwap; });
|
||||
view.appendChild(pip);
|
||||
this.view.appendChild(pip);
|
||||
|
||||
// ⬚ frame guide — DOM ONLY. The viewport is whatever shape the CSS grid gives it and _render
|
||||
// rewrites cam.aspect every draw, so headroom / lead room / a plate's fitAnchor horizon were
|
||||
// judged in a rectangle that is never the encoded one. Bars mask it down to the delivery
|
||||
// aspect. Being DOM (z BELOW the PiP, pointer-events:none) it can never reach a captured frame.
|
||||
const fg = this._fg = document.createElement('div'); fg.className = 'frameguide';
|
||||
fg.innerHTML = '<i class="fgb t"></i><i class="fgb b"></i><i class="fgb l"></i><i class="fgb r"></i>'
|
||||
+ '<i class="fggate"><i class="fgv" style="left:33.333%"></i><i class="fgv" style="left:66.667%"></i>'
|
||||
+ '<i class="fgh" style="top:33.333%"></i><i class="fgh" style="top:66.667%"></i>'
|
||||
+ '<i class="fgsafe"></i></i>';
|
||||
this._frameAspect = 16/9;
|
||||
this.view.appendChild(fg);
|
||||
|
||||
const scene = this.scene = new THREE.Scene();
|
||||
const cam = this._dirCam = new THREE.PerspectiveCamera(45, 1, 0.01, 3000);
|
||||
@ -101,7 +209,11 @@ export class Stage {
|
||||
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose();
|
||||
// default lights — replaced/dimmed once the scene carries light entities
|
||||
this._defHemi = new THREE.HemisphereLight(0xffffff, 0x555a66, 2.2); scene.add(this._defHemi);
|
||||
this._defKey = new THREE.DirectionalLight(0xffffff, 1.8); this._defKey.position.set(3, 5, 2); scene.add(this._defKey);
|
||||
// the default key CASTS: without this nothing threw a shadow until the user added a light
|
||||
// entity, so characters hovered on an untouched stage despite shadowMap.enabled.
|
||||
this._defKey = new THREE.DirectionalLight(0xffffff, 1.8); this._defKey.position.set(3, 5, 2);
|
||||
this._shadowRig(this._defKey); scene.add(this._defKey);
|
||||
this._shadowDirty = true; // consumed once per DRAWN frame in _render — never fit per light-key
|
||||
|
||||
const floor = this._floor = new THREE.Mesh(new THREE.CircleGeometry(30, 64).rotateX(-Math.PI/2),
|
||||
new THREE.MeshStandardMaterial({color:0x141a20, roughness:.97}));
|
||||
@ -117,9 +229,13 @@ export class Stage {
|
||||
scene.add(tc);
|
||||
addEventListener('keydown', e => {
|
||||
if(!this._selected) return;
|
||||
// A light has exactly ONE degree of freedom that does anything: position. Its target is
|
||||
// nailed to the stage origin (_buildLight/_aimLight), so `e`/`r` used to swing a gizmo,
|
||||
// fire onChange and key a rotation that changed no pixel. Refuse the mode instead.
|
||||
const posOnly = this.getEntity(this._selected)?.kind === 'light';
|
||||
if(e.key==='w') tc.setMode('translate');
|
||||
else if(e.key==='e') tc.setMode('rotate');
|
||||
else if(e.key==='r') tc.setMode('scale');
|
||||
else if(e.key==='e' && !posOnly) tc.setMode('rotate');
|
||||
else if(e.key==='r' && !posOnly) tc.setMode('scale');
|
||||
});
|
||||
|
||||
r.domElement.addEventListener('click', e => this._pick(e));
|
||||
@ -127,7 +243,7 @@ export class Stage {
|
||||
this._resize(); addEventListener('resize', () => this._resize());
|
||||
const loop = () => { requestAnimationFrame(loop);
|
||||
const dt = this.clock.getDelta();
|
||||
if(this._paused) return; // M3: caller drives rendering deterministically
|
||||
if(this._paused) return; // M3: caller drives rendering deterministically (_render refits)
|
||||
ctr.update();
|
||||
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
|
||||
// inset first (it scribbles onto the main canvas), then the main view last so it wins
|
||||
@ -143,9 +259,69 @@ export class Stage {
|
||||
// size the inset here — CSS aspect-ratio/% is unreliable on a <canvas>
|
||||
const pw = Math.max(180, Math.min(340, w*0.24));
|
||||
this._pip.style.width = Math.round(pw)+'px'; this._pip.style.height = Math.round(pw*9/16)+'px';
|
||||
this._layoutGuide();
|
||||
this.fitBackdrops(); // frame aspect changed → auto-fit plates no longer fill it
|
||||
}
|
||||
|
||||
// ---- ⬚ frame guide (DOM overlay; see the constructor) ----
|
||||
_layoutGuide(){
|
||||
const fg = this._fg; if(!fg) return;
|
||||
const b = guideBars(this.view.clientWidth, this.view.clientHeight, this._frameAspect);
|
||||
const px = n => Math.round(n) + 'px';
|
||||
const [t, bo, l, r, gate] = fg.children;
|
||||
t.style.height = px(b.top); bo.style.height = px(b.bottom);
|
||||
l.style.width = px(b.left); r.style.width = px(b.right);
|
||||
gate.style.top = px(b.top); gate.style.bottom = px(b.bottom);
|
||||
// THE GATE IS THE ENCODED FRAME, even when that frame is wider than the window: a negative
|
||||
// inset runs it off both sides (.frameguide clips it) so its thirds and action-safe box —
|
||||
// percentages of the gate — mark the real frame instead of the visible crop. Clamped to the
|
||||
// viewport they were ~6% of the frame width out on a tall window, and off by more the taller
|
||||
// it got, which is precisely when a director needs them.
|
||||
gate.style.left = px(b.side); gate.style.right = px(b.side);
|
||||
// overscan: the delivery frame is WIDER than this viewport, so its sides are off-screen and
|
||||
// no bar can show that. Say it (the dashed edges + note live on the guide, not on the gate,
|
||||
// because the gate's own edges are now off-screen).
|
||||
fg.classList.toggle('over', b.overscan > 1.001);
|
||||
}
|
||||
setFrameAspect(a){ if(a > 0){ this._frameAspect = a; this._layoutGuide(); } return this._frameAspect; }
|
||||
setFrameGuide(on){ if(this._fg) this._fg.style.display = on ? '' : 'none'; return !!on; }
|
||||
frameGuide(){ return !!this._fg && this._fg.style.display !== 'none'; }
|
||||
frameAspect(){ return this._frameAspect; }
|
||||
|
||||
// ---- shadows ----
|
||||
// Shared config for every shadow-casting directional light (the default key + scene keys).
|
||||
// normalBias kills the acne a 2048 map still shows on a 1.7m character at a grazing sun.
|
||||
_shadowRig(dir){
|
||||
dir.castShadow = true;
|
||||
dir.shadow.mapSize.set(2048, 2048);
|
||||
dir.shadow.camera.far = 60;
|
||||
dir.shadow.normalBias = 0.02; dir.shadow.bias = -0.0005;
|
||||
}
|
||||
// Fit every shadow camera's ortho box to what is actually on stage. three's default ±5 units
|
||||
// sliced shadows off dead straight the moment two characters stood more than ~10m apart, and
|
||||
// left near/far unrelated to where the boom is. Recomputed at most once per frame (_shadowDirty).
|
||||
_fitShadows(){
|
||||
const boxes = [];
|
||||
for(const en of this._entities.values()){
|
||||
if(en.kind !== 'character' && en.kind !== 'prop' && en.kind !== 'screen') continue;
|
||||
const b = this.entityBBox(en.id); if(b) boxes.push(b);
|
||||
}
|
||||
const h = shadowHalfExtent(boxes);
|
||||
const p = new THREE.Vector3();
|
||||
const fit = dir => {
|
||||
if(!dir || !dir.shadow) return;
|
||||
const c = dir.shadow.camera;
|
||||
c.left = -h; c.right = h; c.top = h; c.bottom = -h;
|
||||
c.near = 0.5;
|
||||
dir.updateWorldMatrix(true, false); dir.getWorldPosition(p);
|
||||
c.far = p.length() + 2 * h; // boom distance + the depth of the box behind the stage
|
||||
c.updateProjectionMatrix();
|
||||
};
|
||||
fit(this._defKey);
|
||||
for(const en of this._entities.values())
|
||||
if(en.light && en.light.isDirectionalLight) fit(en.light);
|
||||
}
|
||||
|
||||
// ---- callbacks ----
|
||||
onSelect(cb){ this._selCbs.push(cb); }
|
||||
onChange(cb){ this._changeCbs.push(cb); }
|
||||
@ -195,7 +371,8 @@ export class Stage {
|
||||
if(en.kind === 'backdrop'){ this._fitBackdrop(en); this._sampleGround(en); } // M7: world tie-in
|
||||
// _buildLight ran BEFORE the entity was registered, so its _refreshDefaults couldn't see it —
|
||||
// without this the built-in hemi stayed at 2.2 and washed every lighting preset out.
|
||||
if(en.kind === 'light'){ this._refreshDefaults(); this._applyWorld(); }
|
||||
if(en.kind === 'light'){ this._refreshDefaults(); this._applyExposure(); this._applyWorld(); }
|
||||
this._shadowDirty = true;
|
||||
this._fireChange(en); // tlui/dock build a row per entity off onChange (incl. applyState loads)
|
||||
return en;
|
||||
}
|
||||
@ -203,14 +380,22 @@ export class Stage {
|
||||
removeEntity(id){
|
||||
const en = this._entities.get(id); if(!en) return;
|
||||
if(this._selected === id) this.select(null);
|
||||
// A dead active-camera id kept the PiP drawing a duplicate of the director view and made
|
||||
// renderActiveCamera() silently shoot the DIRECTOR cam into the mp4. Fall back to the orbit
|
||||
// cam, which is what `null` has always meant.
|
||||
if(this._activeCamId === id) this.setActiveCamera(null);
|
||||
this._dropVideo(en);
|
||||
if(en.root) disposeRoot(en.root);
|
||||
if(en.light) en.light.parent && en.light.parent.remove(en.light);
|
||||
if(en.target) this.scene.remove(en.target); // the light's aim point lives on the scene
|
||||
this.scene.remove(en.wrapper);
|
||||
this._entities.delete(id);
|
||||
for(const k of [...this._baked.keys()]) if(k.startsWith(id+'|')) this._baked.delete(k);
|
||||
if(en.kind === 'light') this._refreshDefaults();
|
||||
// exposure is recomputed from the ambients that are STILL on stage (sceneExposure) — deleting
|
||||
// one of two ambients used to reset the whole frame to 1.0 and never restore the survivor's.
|
||||
if(en.kind === 'light'){ this._refreshDefaults(); this._applyExposure(); }
|
||||
if(en.kind === 'backdrop' || en.kind === 'light') this._applyWorld(); // floor/grid/fog follow the plates
|
||||
this._shadowDirty = true;
|
||||
this._fireChange(en); // trigger a row resync so the deleted entity drops out
|
||||
}
|
||||
|
||||
@ -266,21 +451,35 @@ export class Stage {
|
||||
}
|
||||
const w = p.width || 10, h = w / aspect;
|
||||
const grp = new THREE.Group(), mats = [];
|
||||
// fog:false — the plate IS the distance; atmosphere is for what stands in front of it
|
||||
if(p.mode === 'dome'){
|
||||
const dm = new THREE.MeshBasicMaterial({map:tex, side:THREE.BackSide, fog:false}); mats.push(dm);
|
||||
const dm = this._plateMat(tex, {side:THREE.BackSide}); mats.push(dm);
|
||||
grp.add(new THREE.Mesh(new THREE.SphereGeometry(40, 40, 24), dm));
|
||||
} else {
|
||||
const mat = new THREE.MeshStandardMaterial({map:tex, roughness:1, metalness:0, fog:false}); mats.push(mat);
|
||||
const mat = this._plateMat(tex); mats.push(mat);
|
||||
const wall = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
|
||||
wall.position.y = h/2; wall.receiveShadow = true; grp.add(wall);
|
||||
wall.position.y = h/2; grp.add(wall);
|
||||
if(p.mode === 'corner'){ // cheap "L": duplicate lower third laid flat as ground
|
||||
const gh = h/3, gm = mat.clone(); mats.push(gm);
|
||||
// The ground is the SAME PHOTOGRAPH as the wall, so it gets the SAME material. It used to
|
||||
// be a MeshStandardMaterial "because it catches the shadow", which rendered one image at
|
||||
// two levels across the fold — lit + tone-mapped below, unlit + untonemapped above, a hard
|
||||
// seam at the L junction (under `noir`, ambient 0.15, the ground copy went near-black
|
||||
// while the wall stayed at full brightness). The shadow now lands on a separate
|
||||
// ShadowMaterial catcher laid 2mm over it: invisible except where something shades it.
|
||||
const gh = h/3, gm = this._plateMat(tex); mats.push(gm);
|
||||
const g = new THREE.Mesh(new THREE.PlaneGeometry(w, gh), gm);
|
||||
g.rotation.x = -Math.PI/2; g.position.z = gh/2; g.receiveShadow = true; grp.add(g);
|
||||
g.rotation.x = -Math.PI/2; g.position.z = gh/2; grp.add(g);
|
||||
// fog:false for the same reason the plate has it: _applyWorld installs a FogExp2 from the
|
||||
// plate's own ground colour, and three's default (fog:true) would have tinted and faded
|
||||
// the SHADOW with distance while the photograph it lands on stayed fog-free — one image,
|
||||
// two atmospheres, which is the seam this catcher was introduced to remove.
|
||||
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(w, gh),
|
||||
new THREE.ShadowMaterial({opacity:0.45, fog:false}));
|
||||
catcher.rotation.x = -Math.PI/2; catcher.position.set(0, 0.002, gh/2);
|
||||
catcher.receiveShadow = true; catcher.userData.shadowCatcher = true; grp.add(catcher);
|
||||
wall.position.z = -gh/2;
|
||||
}
|
||||
}
|
||||
this._plateLevel(mats, p.plateExposure);
|
||||
if(vidTex && poster) this._swapOnPlay(en.video, vidTex, mats);
|
||||
if(en.video && this._mixerAuto) en.video.play().catch(()=>{}); // ambient preview until a timeline owns the clock
|
||||
return grp;
|
||||
@ -300,7 +499,7 @@ export class Stage {
|
||||
const tex = poster || texture;
|
||||
const w = p.width || 3, h = w / aspect;
|
||||
const mat = new THREE.MeshStandardMaterial({ map:tex, emissive:0xffffff, emissiveMap:tex,
|
||||
emissiveIntensity: p.emissive ?? 0.6, roughness:0.4, metalness:0 });
|
||||
emissiveIntensity: p.emissive ?? 0.6, roughness:0.4, metalness:0, toneMapped:false });
|
||||
if(poster) this._swapOnPlay(video, texture, [mat]);
|
||||
if(this._mixerAuto) video.play().catch(()=>{});
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
|
||||
@ -378,6 +577,25 @@ export class Stage {
|
||||
}
|
||||
_unfitBackdrop(en){ if(en.root){ en.root.scale.setScalar(1); en.root.position.set(0,0,0); } }
|
||||
|
||||
// ONE material for every surface that shows the photograph — wall, corner ground, dome.
|
||||
// fog:false — the plate IS the distance; atmosphere is for what stands in front of it.
|
||||
// toneMapped:false + unlit — a photograph/Flow frame is DISPLAY-REFERRED, already lit and
|
||||
// graded; re-lighting it meant `noir` (ambient 0.15) blacked the photograph out, and running
|
||||
// ACES over it a second time muddies its blacks. Level is params.plateExposure (_plateLevel),
|
||||
// never the key light. Anything that shows this image and does NOT come through here will
|
||||
// read at a different level than the rest of the plate.
|
||||
_plateMat(tex, extra){
|
||||
return new THREE.MeshBasicMaterial({map:tex, fog:false, toneMapped:false, ...extra});
|
||||
}
|
||||
|
||||
// plate level: an unlit plate has no key to expose it, so params.plateExposure (default 1) rides
|
||||
// the material colour — same knob for every plate surface (never the shadow catcher, which has
|
||||
// no map and whose `color` is the shadow's own tint).
|
||||
_plateLevel(mats, v){
|
||||
const n = +v, s = Number.isFinite(n) ? Math.max(0, Math.min(4, n)) : 1;
|
||||
for(const m of (mats || [])) if(m && m.color) m.color.setScalar(s);
|
||||
}
|
||||
|
||||
// average colour of the plate's bottom rows — the ground the character should be standing on.
|
||||
// Works for images and for video (poster or current frame); same-origin, so the canvas is clean.
|
||||
_plateGround(en){
|
||||
@ -428,20 +646,48 @@ export class Stage {
|
||||
en.light = new THREE.HemisphereLight(new THREE.Color(p.color||'#ffffff'), 0x444450, p.intensity ?? 1.0);
|
||||
en.wrapper.add(en.light);
|
||||
if(p.bg) this.scene.background = new THREE.Color(p.bg); // saved lighting preset restores its backdrop color
|
||||
// exposure rides the ambient entity exactly like bg (keyable, persists in the scene JSON),
|
||||
// but building a light does NOT set the frame's level — _applyExposure() does, from the
|
||||
// ambients that are on stage, once addEntity has registered this one. Writing it here meant
|
||||
// a dock "+ ambient" (which carries no exposure) stomped a preset's 1.6 down to 1.0.
|
||||
} else {
|
||||
const dir = en.light = new THREE.DirectionalLight(new THREE.Color(p.color||'#ffffff'), p.intensity ?? 1.5);
|
||||
this._shadowRig(dir);
|
||||
dir.castShadow = p.castShadow ?? true;
|
||||
dir.shadow.mapSize.set(1024,1024); dir.shadow.camera.far = 60;
|
||||
dir.position.set(0,0,0);
|
||||
const tgt = new THREE.Object3D(); tgt.position.set(0,0,-1); en.wrapper.add(tgt); dir.target = tgt;
|
||||
// The aim point is nailed to the STAGE ORIGIN and lives on the SCENE, not on the light's
|
||||
// wrapper. As a child at z=−1 it travelled with the light, so gizmo-translating a key light
|
||||
// 15m along X carried the whole light→target axis with it: the ortho shadow box (which
|
||||
// shadowHalfExtent measures from the origin) went with it and the character silently lost
|
||||
// his shadow. A sun's aim IS its position — elevation/azimuth on a boom, exactly what
|
||||
// applyLight solves.
|
||||
const tgt = en.target = new THREE.Object3D();
|
||||
this.scene.add(tgt); dir.target = tgt;
|
||||
this._aimLight(en);
|
||||
en.wrapper.add(dir);
|
||||
const proxy = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 8),
|
||||
new THREE.MeshBasicMaterial({color:0xffe08a}));
|
||||
proxy.userData.chrome = true; // editor-only, like the camera cone — never in a render
|
||||
en.wrapper.add(proxy);
|
||||
}
|
||||
this._refreshDefaults();
|
||||
this._shadowDirty = true;
|
||||
}
|
||||
|
||||
// Point a key light's target. It sits at the stage origin — that is what makes a light's ANGLE
|
||||
// its position and keeps the ortho shadow box (measured from the origin) over the stage. The one
|
||||
// position that has no direction is the origin itself: gizmo-dragging a sun onto (0,0,0) made
|
||||
// light.position === target.position, a zero-length direction three resolves to NaN (no shadow,
|
||||
// undefined shading). A light standing ON the mark shines straight down.
|
||||
_aimLight(en){
|
||||
if(!en || !en.target) return;
|
||||
const p = en.wrapper.position;
|
||||
en.target.position.set(0, Math.hypot(p.x, p.y, p.z) < 1e-3 ? -1 : 0, 0);
|
||||
}
|
||||
|
||||
// the ACES level, recomputed from the ambients on stage (see sceneExposure)
|
||||
_applyExposure(){ this.renderer.toneMappingExposure = sceneExposure(this.entities()); }
|
||||
|
||||
// dim built-in lights once scene lights exist
|
||||
_refreshDefaults(){
|
||||
const hasKey = this.entities().some(e => e.kind==='light' && e.params.type!=='ambient');
|
||||
@ -453,8 +699,16 @@ export class Stage {
|
||||
// ---- selection + gizmo ----
|
||||
select(id){
|
||||
this._selected = id;
|
||||
if(id == null){ this._tc.detach(); }
|
||||
else { const en = this._entities.get(id); if(en) this._tc.attach(en.wrapper); }
|
||||
const en = id == null ? null : this._entities.get(id);
|
||||
// A frustum-fitted plate is placed BY THE LENS: dragging its gizmo visibly moved it until the
|
||||
// next refit yanked it back with no explanation. No gizmo — selection/inspector still work.
|
||||
if(!en || (en.kind === 'backdrop' && en.params && en.params.fit === 'frustum')) this._tc.detach();
|
||||
else {
|
||||
this._tc.attach(en.wrapper);
|
||||
// …and never hand a light a rotate/scale gizmo left over from the last selection (see the
|
||||
// `w`/`e`/`r` handler: only its position means anything).
|
||||
if(en.kind === 'light') this._tc.setMode?.('translate');
|
||||
}
|
||||
for(const cb of this._selCbs) cb(id ? this._entities.get(id) : null);
|
||||
}
|
||||
_pick(e){
|
||||
@ -486,13 +740,21 @@ export class Stage {
|
||||
// re-applies every entity's rest transform each tick, which dragged the plate back to
|
||||
// its authored pos and left a black bar at frame top. Camera owns it; ignore the rest.
|
||||
if(en.kind === 'backdrop' && en.params && en.params.fit === 'frustum'){ this._fitBackdrop(en); return; }
|
||||
// ponytail: only a light that ACTUALLY MOVED refits (the box's far plane is the boom
|
||||
// distance). The timeline re-applies every entity's transform each tick, so flagging
|
||||
// characters — or flagging a keyed sun that lands on the same pos every frame — would run a
|
||||
// Box3.setFromObject sweep over skinned meshes every frame; add/remove already covers the
|
||||
// cases that change the box's size.
|
||||
const moved = en.kind === 'light' && pos && !samePos(pos, [w.position.x, w.position.y, w.position.z]);
|
||||
if(pos) w.position.set(pos[0], pos[1], pos[2]);
|
||||
if(rot) w.rotation.set(rot[0], rot[1], rot[2]);
|
||||
if(scale != null) w.scale.setScalar(scale);
|
||||
if(moved){ this._aimLight(en); this._shadowDirty = true; }
|
||||
}
|
||||
setParam(id, key, value){
|
||||
const en = this._entities.get(id); if(!en) return;
|
||||
en.params[key] = value;
|
||||
if(en.kind === 'light' && shadowAffectingParam(key)) this._shadowDirty = true;
|
||||
if(en.cam && key === 'fov'){ en.cam.fov = value; en.cam.updateProjectionMatrix();
|
||||
if(en.id === this._activeCamId) this.fitBackdrops(); } // the frame changed shape
|
||||
if(en.light){
|
||||
@ -501,11 +763,22 @@ export class Stage {
|
||||
if(key === 'castShadow' && en.light.isDirectionalLight) en.light.castShadow = !!value;
|
||||
if(key === 'bg') this.scene.background = new THREE.Color(value); // bg rides the ambient entity (keyable, persists)
|
||||
if(key === 'fog') this._applyWorld(); // keyable atmosphere, same pattern as bg
|
||||
// overall level for the whole frame. Goes through the SAME rule as add/remove (the last
|
||||
// ambient that states an exposure owns it) so there is one answer, and it is clamped so a
|
||||
// keyed lerp through 0 can't black the film out.
|
||||
if(key === 'exposure') this._applyExposure();
|
||||
}
|
||||
if(en.kind === 'backdrop'){
|
||||
if(key === 'fit') value === 'frustum' ? this._fitBackdrop(en) : this._unfitBackdrop(en);
|
||||
if(key === 'fit'){ value === 'frustum' ? this._fitBackdrop(en) : this._unfitBackdrop(en);
|
||||
if(this._selected === en.id) this.select(en.id); } // frustum ⇄ free swaps the gizmo
|
||||
if(key === 'fitDist' || key === 'fitAnchor') this._fitBackdrop(en);
|
||||
if(key === 'groundTint') this._applyWorld();
|
||||
if(key === 'plateExposure' && en.root){
|
||||
// map-bearing materials only: the corner shadow catcher is a ShadowMaterial whose
|
||||
// `color` is the shadow tint — setScalar(1) on it would erase the shadow.
|
||||
const mats = []; en.root.traverse(o => { if(o.isMesh && o.material && o.material.map) mats.push(o.material); });
|
||||
this._plateLevel(mats, value);
|
||||
}
|
||||
}
|
||||
const rebuild = () => this._rebuild(en).catch(e => console.warn('media rebuild failed:', en.id, e));
|
||||
if(en.kind === 'backdrop' && (key === 'mode' || key === 'image' || key === 'width'))
|
||||
@ -600,6 +873,11 @@ export class Stage {
|
||||
for(const en of this._entities.values()) if(en.helper) en.helper.visible = visible;
|
||||
}
|
||||
_render(cam, canvas){
|
||||
// Consume the shadow refit HERE rather than in the rAF loop: the final render drives _render
|
||||
// directly (renderActiveCamera, while _paused), so a fit that only fired on a rAF callback
|
||||
// made which frames saw the new ortho box depend on browser timing instead of on
|
||||
// timeline.step(). Still ≤1 refit per drawn frame — the PiP pass clears the flag for the main.
|
||||
if(this._shadowDirty){ this._shadowDirty = false; this._fitShadows(); }
|
||||
const w = canvas ? canvas.width : this.renderer.domElement.width;
|
||||
const h = canvas ? canvas.height : this.renderer.domElement.height;
|
||||
const prev = cam.aspect;
|
||||
@ -632,6 +910,12 @@ export class Stage {
|
||||
const p = new THREE.Vector3(); this._activeCam().getWorldPosition(p); return p.toArray();
|
||||
}
|
||||
viewAspect(){ return this._activeAspect(); } // M11: a truck is framed in the REAL frame, not 16:9
|
||||
// "stamp a camera on the view you are looking at" — the orbit cam's own state, so a new camera
|
||||
// lands where you already composed instead of at a hardcoded [0,1.6,4] you then guess back.
|
||||
directorCamState(){
|
||||
const c = this._dirCam, p = c.position, r = c.rotation; // .rotation is already an XYZ euler
|
||||
return { pos:[p.x, p.y, p.z], rot:[r.x, r.y, r.z], fov: c.fov };
|
||||
}
|
||||
|
||||
// ---- video plates (M6): master-clock sync + deterministic render seeks ----
|
||||
videos(){ return this.entities().filter(e => e.video)
|
||||
@ -647,7 +931,9 @@ export class Stage {
|
||||
for(const en of this._entities.values()) if(en.videoTex) en.videoTex.needsUpdate = true;
|
||||
for(const { video, start } of this.videos()){
|
||||
if(video.readyState < 1 || !isFinite(video.duration) || video.duration <= 0) continue;
|
||||
const vt = (((t - start) % video.duration) + video.duration) % video.duration;
|
||||
// videoTimeAt CLAMPS (matching Timeline._evalVideo) — it used to WRAP here, so a plate with
|
||||
// videoStart:2 on a 10s clip showed frame 0 while scrubbing and the 8.0s frame in the mp4.
|
||||
const vt = videoTimeAt(t, start, video.duration);
|
||||
if(!video.paused) video.pause();
|
||||
if(Math.abs(video.currentTime - vt) < 0.004) continue; // already on this frame
|
||||
jobs.push(new Promise(res => {
|
||||
|
||||
@ -5,10 +5,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const src = fs.readFileSync(new URL('./stage.js', import.meta.url), 'utf8')
|
||||
.replace(/^import .*from ['"](three(\/[^'"]*)?|\.\/room3d\.js)['"];$/gm, '');
|
||||
const { fitScale, BLEED } = await import(
|
||||
const srcText = fs.readFileSync(new URL('./stage.js', import.meta.url), 'utf8');
|
||||
const src = srcText.replace(/^import .*from ['"](three(\/[^'"]*)?|\.\/room3d\.js)['"];$/gm, '');
|
||||
const { fitScale, BLEED, shadowHalfExtent, guideBars, videoTimeAt, clampExposure,
|
||||
sceneExposure, shadowAffectingParam, samePos, Stage } = await import(
|
||||
'data:text/javascript;base64,' + Buffer.from(src).toString('base64'));
|
||||
// dock.js is pure except for one room3d import — same trick, so its inspector/delete rules
|
||||
// (which are Lane A's too) can be asserted here rather than only in a browser.
|
||||
const { deleteWarning, dropCuts, keyedParam, Dock } = await import('data:text/javascript;base64,' + Buffer.from(
|
||||
fs.readFileSync(new URL('./dock.js', import.meta.url), 'utf8')
|
||||
.replace(/^import .*from ['"]\.\/room3d\.js['"];$/gm, '')).toString('base64'));
|
||||
|
||||
// a 16:9 plate built 10m wide (dock default) seen by a 45° camera 14m away (fitDist default)
|
||||
const camA = 16/9, fov = 45, d = 14, pw = 10, ph = 10 / (16/9);
|
||||
@ -31,6 +37,404 @@ assert.ok(Math.abs(far.s / near.s - 2) < 1e-9, 'distance only changes parallax,
|
||||
// a longer lens (smaller fov) needs a smaller plate at the same distance
|
||||
assert.ok(fitScale(20, camA, d, pw, ph).s < f.s, 'tighter fov → smaller plate');
|
||||
|
||||
// ---- shadow box fit -------------------------------------------------------------------------
|
||||
// The ortho box is centred on the light→target axis, and every light here aims at the STAGE
|
||||
// ORIGIN — so what matters is reach FROM THE ORIGIN, not the entities' own extent.
|
||||
const person = (x = 0, z = 0) => ({ min: [x-0.3, 0, z-0.3], max: [x+0.3, 1.7, z+0.3] });
|
||||
assert.equal(shadowHalfExtent([]), 4, 'empty scene → the 4m floor box');
|
||||
assert.equal(shadowHalfExtent(null), 4, 'no bboxes at all → the floor box');
|
||||
// one 1.7m × 0.6m character at the origin: half-diagonal 0.95 + the 6m ground margin
|
||||
const one = shadowHalfExtent([person()]);
|
||||
assert.ok(Math.abs(one - (Math.hypot(0.6, 1.7, 0.6)/2 + 6)) < 1e-12, 'single character = half-diagonal + margin');
|
||||
assert.ok(one > 5, 'and already bigger than three\'s default ±5');
|
||||
// COVERAGE is the property that matters: every corner of every bbox must sit inside ±h of the
|
||||
// origin. This is what the un-fitted ±5 default failed on — assert it, don't assume it.
|
||||
const covers = (boxes, what) => { const h = shadowHalfExtent(boxes);
|
||||
for(const b of boxes) for(const p of [b.min, b.max]) for(let i = 0; i < 3; i++)
|
||||
assert.ok(Math.abs(p[i]) <= h, `${what}: ${p[i]} outside ±${h}`);
|
||||
return h; };
|
||||
const pair = covers([person(-4), person(4)], 'two characters 8m apart');
|
||||
assert.ok(pair > 5, `two characters 8m apart need more than the default ±5 (got ${pair})`);
|
||||
// the case a centre-relative radius would get WRONG: one character standing 8m off-axis. Half his
|
||||
// own diagonal is 0.95 → 6.95 < 8.3 and his shadow is guillotined; measuring from the origin fits.
|
||||
const off = covers([person(8)], 'a lone character 8m off-axis');
|
||||
assert.ok(off > 8.3, `off-axis character must be inside the box (h=${off} > 8.3)`);
|
||||
assert.ok(off > Math.hypot(0.6, 1.7, 0.6)/2 + 6, 'off-axis fit beats the centre-relative formula');
|
||||
// a bigger margin only ever grows the box, and the floor is the hard minimum
|
||||
assert.ok(shadowHalfExtent([person()], 12) > one, 'margin grows the box');
|
||||
assert.equal(shadowHalfExtent([person()], 0), 4, 'a tiny scene still gets the 4m floor box');
|
||||
|
||||
// ---- ⬚ frame guide bars ----------------------------------------------------------------------
|
||||
const A = 16/9;
|
||||
const wideView = guideBars(1600, 600, A); // viewport WIDER than the frame → pillarbox
|
||||
assert.ok(Math.abs(wideView.left - (1600 - 600*A)/2) < 1e-9 && wideView.left === wideView.right,
|
||||
'wide viewport gets left/right bars of (1600 − 1066.67)/2');
|
||||
assert.equal(wideView.top + wideView.bottom, 0, '…and no top/bottom bars');
|
||||
// A NARROW viewport does NOT letterbox. three's fov is VERTICAL and _render only ever rewrites
|
||||
// cam.aspect, so the encoded frame keeps exactly the viewport's vertical extent and gains WIDTH.
|
||||
// This used to return 96.875px top and bottom at 900×700 — masking content that lands in the mp4
|
||||
// and implying side content that does not exist.
|
||||
const tallView = guideBars(900, 700, A);
|
||||
assert.equal(tallView.top, 0, 'a narrow viewport gets NO top bar (the render is wider, not shorter)');
|
||||
assert.equal(tallView.bottom, 0, '…and no bottom bar');
|
||||
assert.equal(tallView.left + tallView.right, 0, '…and nothing to mask at the sides either');
|
||||
assert.ok(Math.abs(tallView.overscan - A / (900/700)) < 1e-12,
|
||||
'overscan reports how much wider the encoded frame is than the viewport');
|
||||
assert.equal(guideBars(800, 800, A).top, 0, 'square viewport: still no letterbox');
|
||||
assert.deepEqual(guideBars(1920, 1080, A), { top:0, bottom:0, left:0, right:0, overscan:1, side:0 },
|
||||
'exact aspect → no bars, no overscan');
|
||||
assert.equal(guideBars(1600, 600, A).overscan, 1, 'pillarbox case is never overscanned');
|
||||
for(const bad of [[0, 0], [1600, 0], [0, 900]])
|
||||
assert.deepEqual(guideBars(bad[0], bad[1], A), { top:0, bottom:0, left:0, right:0, overscan:1, side:0 },
|
||||
'a zero-size viewport returns zeros, never NaN');
|
||||
// THE MARKS INSIDE THE GATE. Thirds and the action-safe box are drawn as percentages of the gate
|
||||
// rectangle, so the gate has to BE the encoded frame — including the part of it that is off-screen
|
||||
// when the viewport is narrower than delivery. Clamped to the viewport (`left`/`right`, which are
|
||||
// 0 in that branch) the thirds landed at 33.3% of the VISIBLE crop, ~6% of the frame width away
|
||||
// from where they actually fall — on the one guide a director composes with.
|
||||
{
|
||||
const w = 900, h = 700, b = guideBars(w, h, A);
|
||||
const frameW = h * A; // 1244.44px of viewport scale
|
||||
assert.ok(b.side < 0, 'a narrow viewport: the frame runs off BOTH sides, so the inset is negative');
|
||||
assert.ok(Math.abs(b.side - (w - frameW) / 2) < 1e-9, 'and it is exactly half the overflow');
|
||||
const third = b.side + frameW / 3; // where the gate's 33.333% mark lands
|
||||
assert.ok(Math.abs(third - 242.593) < 0.01, `left third sits at ${third.toFixed(1)}px, not 300px`);
|
||||
assert.ok(Math.abs(third - w / 3) > 50, 'which is nowhere near the viewport third it used to draw');
|
||||
const safe = b.side + frameW * 0.05; // .fgsafe's 5% side inset
|
||||
assert.ok(safe < 0, 'action-safe on a 16:9 frame in a 900×700 window is off-screen — say so by geometry');
|
||||
// and the guide must still agree with itself where there IS no overscan
|
||||
const p = guideBars(1600, 600, A);
|
||||
assert.equal(p.side, p.left, 'pillarbox: the gate inset IS the bar thickness');
|
||||
}
|
||||
// …and the DOM layer must actually place the gate there (the numbers above are worth nothing if
|
||||
// _layoutGuide keeps clamping them). Five <i>: t, b, l, r, gate.
|
||||
{
|
||||
const kid = () => ({ style:{} });
|
||||
const fg = { children:[kid(), kid(), kid(), kid(), kid()],
|
||||
classList:{ on:new Set(), toggle(c, v){ v ? this.on.add(c) : this.on.delete(c); } } };
|
||||
const g = { _fg: fg, view:{ clientWidth:900, clientHeight:700 }, _frameAspect: A,
|
||||
_layoutGuide: Stage.prototype._layoutGuide };
|
||||
g._layoutGuide();
|
||||
const gate = fg.children[4];
|
||||
assert.equal(gate.style.left, '-172px', 'the gate hangs off the left of a narrow viewport');
|
||||
assert.equal(gate.style.right, '-172px', '…and the right');
|
||||
assert.equal(gate.style.top, '0px', 'never off the top: the vertical extent is always exact');
|
||||
assert.ok(fg.classList.on.has('over'), 'and the crop is disclosed');
|
||||
g.view = { clientWidth:1600, clientHeight:600 }; g._layoutGuide();
|
||||
assert.equal(gate.style.left, '267px', 'a wide viewport pillarboxes exactly as before');
|
||||
assert.equal(fg.children[2].style.width, '267px', '…with a matching bar');
|
||||
assert.ok(!fg.classList.on.has('over'), 'and no crop note');
|
||||
}
|
||||
assert.ok(Object.values(guideBars(1600, 600, 2.39)).every(Number.isFinite), 'scope aspect is finite too');
|
||||
// TEETH: model what the renderer actually encodes and require the guide to mask exactly the
|
||||
// viewport area that is NOT in it — no more (would hide real content), no less (would flatter a
|
||||
// composition that gets cropped). Bar thicknesses that merely leave a 16:9 rectangle behind pass
|
||||
// the old, weaker check while being geometrically wrong; this one cannot.
|
||||
const encoded = (w, h, a) => ({ w: h * a, h }); // fixed vertical extent, width from the aspect
|
||||
for(const [w, h] of [[1600,600], [1920,1080], [900,700], [800,800], [640,361], [500,1200]]){
|
||||
const e = encoded(w, h, A), b = guideBars(w, h, A);
|
||||
const gw = w - b.left - b.right, gh = h - b.top - b.bottom;
|
||||
assert.ok(gh >= Math.min(h, e.h) - 1e-9, `${w}×${h}: guide hides ${(h - gh).toFixed(1)}px of encoded HEIGHT`);
|
||||
assert.ok(gw >= Math.min(w, e.w) - 1e-9, `${w}×${h}: guide hides encoded width`);
|
||||
assert.ok(gh <= Math.min(h, e.h) + 1e-9 && gw <= Math.min(w, e.w) + 1e-9,
|
||||
`${w}×${h}: guide leaves visible area that is NOT encoded`);
|
||||
assert.ok(Math.abs(b.overscan - Math.max(1, e.w / w)) < 1e-9,
|
||||
`${w}×${h}: overscan ${b.overscan} misreports the off-screen width`);
|
||||
}
|
||||
|
||||
// ---- videoStart means ONE thing (syncVideos ⇄ Timeline._evalVideo) ----------------------------
|
||||
// _evalVideo clamps with Math.max(0, t - start) % dur; syncVideos used to WRAP, so a plate with
|
||||
// videoStart:2 showed frame 0 while scrubbing and the 8.0s frame in the mp4. Same function now.
|
||||
assert.equal(videoTimeAt(0, 2, 10), 0, 'before the start → frame 0, not the tail of the clip');
|
||||
assert.equal(videoTimeAt(3, 2, 10), 1, '1s in');
|
||||
assert.equal(videoTimeAt(13, 2, 10), 1, 'past one loop → wraps within the clip');
|
||||
assert.equal(videoTimeAt(1, 0, 0), 0, 'zero duration → 0, never NaN');
|
||||
assert.equal(videoTimeAt(5, 0, NaN), 0, 'undecoded duration → 0, never NaN');
|
||||
assert.equal(videoTimeAt(1.5, 0, 10), 1.5, 'no offset = the timeline clock');
|
||||
// what the OLD wrap did, kept as the thing we must never match again
|
||||
assert.notEqual(videoTimeAt(0, 2, 10), (((0 - 2) % 10) + 10) % 10, 'no longer the wrapped 8.0s frame');
|
||||
|
||||
// ---- exposure clamp (a keyed lerp through 0 must not black the frame) ------------------------
|
||||
assert.equal(clampExposure(1), 1, 'unity passes through');
|
||||
assert.equal(clampExposure(0), 0.05, 'a key at 0 clamps to the floor, not black');
|
||||
assert.equal(clampExposure(-3), 0.05, 'negative clamps to the floor');
|
||||
assert.equal(clampExposure(99), 4, 'clamps to the ceiling');
|
||||
assert.equal(clampExposure(undefined), 1, 'no exposure on an old scene → 1.0, unchanged');
|
||||
assert.equal(clampExposure('1.6'), 1.6, 'a stringy JSON value still works');
|
||||
|
||||
// ---- exposure OWNERSHIP: the level belongs to the scene, not to the last light built ----------
|
||||
// `night` (1.6) then dock "+ ambient" used to drop the whole frame to 1.0 (the new entity carries
|
||||
// no exposure), and deleting either ambient reset it to 1.0 even with the other one still holding
|
||||
// the level. sceneExposure is the single rule; _buildLight/removeEntity/setParam all defer to it.
|
||||
const amb = (exposure) => ({ kind:'light', params: exposure == null ? {type:'ambient'} : {type:'ambient', exposure} });
|
||||
assert.equal(sceneExposure([]), 1, 'no lights at all → 1.0');
|
||||
assert.equal(sceneExposure([amb()]), 1, 'an ambient that states no exposure → 1.0');
|
||||
assert.equal(sceneExposure([amb(1.6)]), 1.6, 'the ambient owns the level');
|
||||
assert.equal(sceneExposure([amb(1.6), amb()]), 1.6,
|
||||
'a SECOND ambient with no exposure must not stomp the one that has it');
|
||||
assert.equal(sceneExposure([amb(1.6), amb(1.1)]), 1.1, 'last ambient that states one wins');
|
||||
assert.equal(sceneExposure([{kind:'light', params:{type:'key', exposure:3}}]), 1,
|
||||
'a key light is not where exposure lives');
|
||||
assert.equal(sceneExposure([amb(0)]), 0.05, 'still clamped — a keyed lerp through 0 cannot black the film');
|
||||
|
||||
// the same rule, exercised through the REAL Stage methods with a stubbed three.js
|
||||
globalThis.THREE = {
|
||||
Color: class { constructor(v){ this.v = v; } set(v){ this.v = v; } copy(){ return this; }
|
||||
clone(){ return this; } multiplyScalar(){ return this; } getHex(){ return 0; } setScalar(s){ this.s = s; } },
|
||||
HemisphereLight: class { constructor(c, g, i){ this.color = c; this.intensity = i; } },
|
||||
DirectionalLight: class { constructor(c, i){ this.color = c; this.intensity = i; this.isDirectionalLight = true;
|
||||
this.shadow = { mapSize:{ set(){} }, camera:{ updateProjectionMatrix(){} } }; this.position = { set(){} }; } },
|
||||
Object3D: class { constructor(){ this.position = { x:0, y:0, z:0, set(x,y,z){ this.x=x; this.y=y; this.z=z; } };
|
||||
this.children = []; this.parent = null; } add(o){ o.parent = this; this.children.push(o); }
|
||||
remove(o){ const i = this.children.indexOf(o); if(i >= 0) this.children.splice(i, 1); } },
|
||||
Mesh: class { constructor(g, m){ this.geometry = g; this.material = m; this.isMesh = true; this.userData = {};
|
||||
this.position = { x:0, y:0, z:0, set(x,y,z){ this.x=x; this.y=y; this.z=z; } }; this.rotation = { x:0, y:0, z:0 }; } },
|
||||
Group: class { constructor(){ this.children = []; this.userData = {};
|
||||
this.position = { x:0, y:0, z:0, set(){} }; this.rotation = { set(){} }; this.scale = { setScalar(){} }; }
|
||||
add(o){ this.children.push(o); } remove(o){ const i = this.children.indexOf(o);
|
||||
if(i >= 0) this.children.splice(i, 1); } },
|
||||
SphereGeometry: class {}, PlaneGeometry: class { constructor(w, h){ this.parameters = { width:w, height:h }; } },
|
||||
MeshBasicMaterial: class { constructor(o = {}){ Object.assign(this, o); this.color = { setScalar(s){ this.s = s; } }; } },
|
||||
MeshStandardMaterial: class { constructor(o = {}){ Object.assign(this, o); this.color = { setScalar(s){ this.s = s; } }; } },
|
||||
ShadowMaterial: class { constructor(o = {}){ Object.assign(this, o); this.color = { setScalar(s){ this.s = s; } }; } },
|
||||
TextureLoader: class { async loadAsync(){ return { image: { width:1920, height:1080 } }; } },
|
||||
SRGBColorSpace: 'srgb', BackSide: 1,
|
||||
};
|
||||
const P = Stage.prototype;
|
||||
const sceneStub = () => ({ _objs: [], add(o){ this._objs.push(o); }, remove(o){
|
||||
const i = this._objs.indexOf(o); if(i >= 0) this._objs.splice(i, 1); }, traverse(){}, background: null, fog: null });
|
||||
const fakeStage = (exposure = 1) => ({
|
||||
renderer: { toneMappingExposure: exposure },
|
||||
scene: sceneStub(),
|
||||
_entities: new Map(), _baked: new Map(), _selected: null, _selCbs: [], _changeCbs: [],
|
||||
_tc: { detach(){}, attach(){}, mode: null, setMode(m){ this.mode = m; } }, _pip: { style: {} },
|
||||
_defKey: { visible:true }, _defHemi: { intensity: 2.2 },
|
||||
_shadowDirty: false, _activeCamId: null,
|
||||
_idc: 0,
|
||||
entities: P.entities, getEntity: P.getEntity, _fireChange: P._fireChange,
|
||||
_refreshDefaults: P._refreshDefaults, _applyExposure: P._applyExposure,
|
||||
_shadowRig: P._shadowRig, _buildLight: P._buildLight, _aimLight: P._aimLight, addEntity: P.addEntity,
|
||||
removeEntity: P.removeEntity, setParam: P.setParam, setTransform: P.setTransform,
|
||||
select: P.select, setActiveCamera(id){ this._activeCamId = id; },
|
||||
_applyWorld(){}, _dropVideo(){}, fitBackdrops(){},
|
||||
});
|
||||
const wrapperStub = () => { const w = new THREE.Object3D(); w.scale = { setScalar(){} };
|
||||
w.rotation = { set(){} }; return w; };
|
||||
|
||||
// an ambient that STATES a level still sets it — through the real add path
|
||||
const s0 = fakeStage(1.0);
|
||||
await s0.addEntity({ kind:'light', label:'ambient', params:{ type:'ambient', exposure:1.6 } });
|
||||
assert.equal(s0.renderer.toneMappingExposure, 1.6, 'adding an ambient with an exposure applies it');
|
||||
|
||||
const s1 = fakeStage(1.6); // …as if `night` had just been pressed
|
||||
s1._entities.set('a1', { id:'a1', kind:'light', params:{ type:'ambient', exposure:1.6 }, wrapper: wrapperStub() });
|
||||
// the real add path, exactly what dock's "+ ambient" button runs (no exposure in its params)
|
||||
const a2 = await s1.addEntity({ kind:'light', label:'ambient', params:{ type:'ambient', color:'#ffffff', intensity:1 } });
|
||||
assert.equal(s1.renderer.toneMappingExposure, 1.6, '"+ ambient" must not reset the frame to 1.0');
|
||||
s1.removeEntity(a2.id);
|
||||
assert.equal(s1.renderer.toneMappingExposure, 1.6, 'deleting it leaves the surviving ambient\'s level');
|
||||
s1.removeEntity('a1');
|
||||
assert.equal(s1.renderer.toneMappingExposure, 1, 'the LAST ambient going does return the frame to 1.0');
|
||||
|
||||
// ---- a dead active camera must not stay live ---------------------------------------------------
|
||||
// removeEntity left _activeCamId pointing at the deleted id: the PiP kept drawing (a duplicate of
|
||||
// the director view) and renderActiveCamera(null) silently shot the DIRECTOR cam into the mp4.
|
||||
const s2 = fakeStage();
|
||||
s2._activeCamId = 'c1';
|
||||
s2._entities.set('c1', { id:'c1', kind:'camera', params:{}, wrapper: wrapperStub(), root:null });
|
||||
s2.removeEntity('c1');
|
||||
assert.equal(s2._activeCamId, null, 'deleting the active camera falls back to the director cam');
|
||||
|
||||
// ---- key lights aim at the STAGE ORIGIN (what shadowHalfExtent is measured from) ---------------
|
||||
// The target used to be a child of the light's own wrapper at z=−1, so gizmo-translating a light
|
||||
// carried the light→target axis with it and the ortho box walked off the stage: the character lost
|
||||
// his shadow with no feedback, while this suite happily asserted coverage around the origin.
|
||||
const s3 = fakeStage();
|
||||
const keyEn = { id:'k1', kind:'light', params:{ type:'key' }, wrapper: wrapperStub() };
|
||||
s3._buildLight(keyEn);
|
||||
const tgt = keyEn.light.target;
|
||||
assert.ok(tgt, 'a key light has a target');
|
||||
assert.ok(s3.scene._objs.includes(tgt), 'target is parented to the SCENE…');
|
||||
assert.ok(!keyEn.wrapper.children.includes(tgt), '…never to the light wrapper (it would travel with it)');
|
||||
s3._entities.set('k1', keyEn);
|
||||
s3.setTransform('k1', { pos:[3, 5, 2] });
|
||||
assert.deepEqual([tgt.position.x, tgt.position.y, tgt.position.z], [0, 0, 0],
|
||||
'a light on its boom aims at the stage origin');
|
||||
// …and the one position that has no direction: ON the origin, light.position === target.position
|
||||
// is a zero-length direction (NaN shading, no shadow). A light standing on the mark shines down.
|
||||
s3.setTransform('k1', { pos:[0, 0, 0] });
|
||||
assert.deepEqual([tgt.position.x, tgt.position.y, tgt.position.z], [0, -1, 0],
|
||||
'a light dragged onto the origin aims straight down instead of nowhere');
|
||||
s3.setTransform('k1', { pos:[0, 4, 0] });
|
||||
assert.deepEqual([tgt.position.y], [0], 'and straight overhead is a normal aim again');
|
||||
// Position is a light's ONLY meaningful degree of freedom, so it must never be handed a gizmo
|
||||
// that turns without changing a pixel (the `e`/`r` keys refuse the mode; selection resets it).
|
||||
s3._tc.setMode('rotate');
|
||||
s3.select('k1');
|
||||
assert.equal(s3._tc.mode, 'translate', 'selecting a light resets the gizmo to translate');
|
||||
s3._entities.set('c9', { id:'c9', kind:'character', params:{}, wrapper: wrapperStub() });
|
||||
s3._tc.setMode('rotate'); s3.select('c9');
|
||||
assert.equal(s3._tc.mode, 'rotate', '…and nothing else loses the mode the user chose');
|
||||
assert.match(srcText, /e\.key==='e' && !posOnly/, 'and the rotate KEY is refused for a light too');
|
||||
|
||||
// ---- shadow refits are for MOVES, not for colour ------------------------------------------------
|
||||
// Timeline._evalParams re-applies every keyed param every tick, so "any light key = dirty" ran a
|
||||
// Box3.setFromObject sweep over every character on every frame — the exact cost the ponytail note
|
||||
// in setTransform claims to avoid — and made the render's shadow fit depend on rAF timing.
|
||||
assert.equal(shadowAffectingParam('color'), false, 'colour does not move the shadow box');
|
||||
assert.equal(shadowAffectingParam('intensity'), false, 'nor does intensity');
|
||||
for(const k of ['bg', 'fog', 'exposure']) assert.equal(shadowAffectingParam(k), false, k + ' is look, not geometry');
|
||||
assert.equal(shadowAffectingParam('castShadow'), true, 'castShadow does');
|
||||
assert.ok(samePos([1,2,3], [1,2,3]) && !samePos([1,2,3], [1,2,3.001]) && !samePos([1,2,3], null),
|
||||
'samePos compares component-wise and tolerates a missing side');
|
||||
const s4 = fakeStage();
|
||||
const sun = { id:'s1', kind:'light', params:{ type:'key' }, wrapper: wrapperStub() };
|
||||
s4._entities.set('s1', sun);
|
||||
s4._shadowDirty = false; s4.setParam('s1', 'color', '#ff0000');
|
||||
assert.equal(s4._shadowDirty, false, 'a keyed sun COLOUR must not trigger a per-frame shadow refit');
|
||||
s4._shadowDirty = false; s4.setParam('s1', 'intensity', 2.2);
|
||||
assert.equal(s4._shadowDirty, false, 'nor a keyed intensity');
|
||||
s4.setTransform('s1', { pos:[3,5,2], rot:[0,0,0], scale:1 });
|
||||
assert.equal(s4._shadowDirty, true, 'moving the light DOES refit');
|
||||
s4._shadowDirty = false; s4.setTransform('s1', { pos:[3,5,2], rot:[0,0,0], scale:1 });
|
||||
assert.equal(s4._shadowDirty, false, 're-applying the SAME pose (every tick, from a key) does not');
|
||||
s4._shadowDirty = false; s4.setParam('s1', 'castShadow', false);
|
||||
assert.equal(s4._shadowDirty, true, 'toggling castShadow does');
|
||||
|
||||
// ---- corner backdrops: one photograph, one material --------------------------------------------
|
||||
// The wall was MeshBasic + toneMapped:false and the ground copy a fresh MeshStandardMaterial: the
|
||||
// same image rendered lit + tone-mapped below the fold and neither above it, a hard seam at the L
|
||||
// junction (under `noir` the ground went near-black while the wall stayed at full brightness).
|
||||
const plate = async mode => {
|
||||
const st = { _makeVideo(){}, _posterTex(){}, _swapOnPlay(){},
|
||||
_plateMat: P._plateMat, _plateLevel: P._plateLevel, _buildBackdrop: P._buildBackdrop };
|
||||
const grp = await st._buildBackdrop({ params:{ mode, image:'street.jpg', width:10 },
|
||||
source:{ type:'assets', path:'street.jpg' } });
|
||||
return grp.children;
|
||||
};
|
||||
const corner = await plate('corner');
|
||||
const photo = corner.filter(m => m.material.map); // every surface showing the plate
|
||||
assert.equal(photo.length, 2, 'corner = wall + ground copy of the same photograph');
|
||||
assert.equal(photo[0].material.constructor.name, photo[1].material.constructor.name,
|
||||
'wall and ground use the SAME material model (no lit/unlit seam at the fold)');
|
||||
for(const m of photo){
|
||||
assert.equal(m.material.toneMapped, false, 'a display-referred plate is never tone-mapped twice');
|
||||
assert.equal(m.material.fog, false, 'the plate IS the distance');
|
||||
}
|
||||
const catcher = corner.find(m => !m.material.map);
|
||||
assert.ok(catcher, 'the shadow the lit ground used to catch lands on a dedicated catcher');
|
||||
assert.equal(catcher.material.constructor.name, 'ShadowMaterial', 'catcher is a ShadowMaterial');
|
||||
assert.ok(catcher.receiveShadow, '…and it receives shadows');
|
||||
// …and it lives in the same atmosphere as the photograph it lands on. three defaults materials to
|
||||
// fog:true, and _applyWorld installs a FogExp2 from the plate's own ground colour, so the SHADOW
|
||||
// faded and took the fog's tint while the ground under it (fog:false, by design) did not — one
|
||||
// image, two atmospheres, a milder version of the seam this catcher was added to remove.
|
||||
assert.equal(catcher.material.fog, false, 'the catcher is not fogged over an unfogged plate');
|
||||
assert.ok(catcher.position.y > 0 && catcher.position.y < 0.01, '…floated just over the ground copy');
|
||||
assert.deepEqual((await plate('plane')).map(m => m.material.constructor.name), ['MeshBasicMaterial'],
|
||||
'plane mode is unchanged');
|
||||
// plateExposure must never reach the catcher: setScalar(1) on a ShadowMaterial erases the shadow
|
||||
const mats = []; for(const m of corner) if(m.isMesh && m.material && m.material.map) mats.push(m.material);
|
||||
assert.equal(mats.length, 2, 'the plateExposure sweep picks up only map-bearing materials');
|
||||
|
||||
// ---- no implicit-global DOM appends (static) ---------------------------------------------------
|
||||
// `view.appendChild(…)` only worked because the mount element's id happens to be "view" (named
|
||||
// access on window). A second Stage on any other element appended the canvas/PiP/guide to the
|
||||
// wrong node, or threw. this.view is stored one line earlier.
|
||||
assert.equal(/(^|[^.\w])view\.appendChild/.test(srcText), false,
|
||||
'stage.js appends through this.view, never the implicit global `view`');
|
||||
|
||||
// ---- dock: what a 🗑 delete takes with it -------------------------------------------------------
|
||||
// "never confirm for a camera" let one unconfirmed click leave a cameraCut pointing at a dead id;
|
||||
// the server then rejects the whole scene with 422 on every save.
|
||||
const cam = { id:'e1', kind:'camera', label:'camera' };
|
||||
const tlWith = { scene: { entities: [{ id:'e1', tracks:{ transform:[], params:[], clips:[] } }],
|
||||
cameraCuts: [{ t:0, camera:'e1' }] } };
|
||||
const w1 = deleteWarning(tlWith, cam);
|
||||
assert.equal(w1.cuts, 1, 'the cut that points at this camera is counted');
|
||||
assert.equal(w1.ask, true, 'an unkeyed camera WITH a cut still asks first');
|
||||
assert.match(w1.msg, /1 camera cut goes with it/, 'and the dialog says what it is about to strand');
|
||||
assert.doesNotMatch(w1.msg, /0 keyframes|0 clip blocks/, '…listing only what is actually there');
|
||||
assert.match(w1.msg, /cannot be undone/, '…and that a delete is a one-way door (nothing restores it)');
|
||||
assert.match(deleteWarning({ scene:{ cameraCuts:[{t:0,camera:'e1'},{t:4,camera:'e1'}],
|
||||
entities:[{ id:'e1', tracks:{ transform:[{t:0},{t:1}], params:[], clips:[] } }] } }, cam).msg,
|
||||
/2 keyframes and 2 camera cuts go with it/, 'plurals and the join agree with the counts');
|
||||
assert.deepEqual(w1.cutObjs, tlWith.scene.cameraCuts, 'the cut objects come back so the caller can drop them');
|
||||
|
||||
// DELETING AN ENTITY IS ONE OPERATION ACROSS 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, so removing the cuts inside tl.group() made the delete HALF-undoable: one Ctrl+Z
|
||||
// put the cut back WITHOUT its camera — a dangling cut, i.e. the "cameraCut references non-camera
|
||||
// entity" 422 that the cleanup exists to prevent, one keystroke away, with the entry consumed so
|
||||
// there is no second undo that fixes it. Symmetry is the fix: the cuts leave the way the camera did.
|
||||
{
|
||||
const undoStack = [{ undo(){ /* some earlier edit */ } }];
|
||||
const cuts = [{ t:0, camera:'e1' }, { t:4, camera:'e1' }, { t:6, camera:'e2' }];
|
||||
const tl = { undoStack, scene:{ cameraCuts: cuts },
|
||||
removeCut(c){ const i = this.scene.cameraCuts.indexOf(c); if(i < 0) return;
|
||||
this.scene.cameraCuts.splice(i, 1);
|
||||
this.undoStack.push({ undo: () => this.scene.cameraCuts.splice(i, 0, c) }); } };
|
||||
assert.equal(dropCuts(tl, cuts.filter(c => c.camera === 'e1')), 2, 'both of the camera\'s cuts go');
|
||||
assert.deepEqual(tl.scene.cameraCuts, [{ t:6, camera:'e2' }], 'and only that camera\'s cuts');
|
||||
assert.equal(tl.undoStack.length, 1, 'dropCuts leaves no undo entry of its own');
|
||||
// NOTE (orchestrator, round 3): the assertion that used to sit here — "undoing
|
||||
// everything on the stack can never resurrect a cut whose camera is gone" — was
|
||||
// VACUOUS. The one surviving entry is the hand-seeded no-op above, so draining
|
||||
// the stack could not fail for the reason it named, and the property is false
|
||||
// against the real Timeline (an older removeCut/addCut entry resurrects it).
|
||||
// The invariant is now enforced in Timeline.toJSON(); timeline_test.mjs owns
|
||||
// the honest version of this check, against real histories.
|
||||
assert.equal(dropCuts(null, cuts), 0, 'no timeline mounted → nothing to do');
|
||||
assert.equal(dropCuts({}, cuts), 0, 'a timeline without removeCut is left alone');
|
||||
}
|
||||
const tlBare = { scene: { entities: [{ id:'e1', tracks:{ transform:[], params:[], clips:[] } }], cameraCuts: [] } };
|
||||
assert.equal(deleteWarning(tlBare, cam).ask, false, 'a camera with no keys and no cuts is still one click');
|
||||
assert.equal(deleteWarning({ scene:{ entities:[{ id:'e1', tracks:{ transform:[{t:0}], params:[], clips:[] } }],
|
||||
cameraCuts: [] } }, cam).ask, true, 'keyframes still ask');
|
||||
assert.equal(deleteWarning(null, cam).ask, false, 'no timeline → never confirm for a camera, as before');
|
||||
assert.equal(deleteWarning(null, { id:'x', kind:'character', label:'lady' }).ask, true,
|
||||
'no timeline → still confirm for a mesh');
|
||||
|
||||
// ---- dock: an inspector param edit must reach the timeline's model, not just the stage ---------
|
||||
// Timeline._evalVideo reads its OWN params snapshot: with the edit landing only on the stage,
|
||||
// scrubbing showed one frame of a video plate and the render encoded another.
|
||||
const seen = [];
|
||||
const tlSync = { syncFromStage(){ seen.push('sync'); } };
|
||||
globalThis.window = { timeline: tlSync };
|
||||
Dock.prototype._param.call({ stage: { setParam(id, k, v){ seen.push(['param', id, k, v]); } } },
|
||||
'b1', 'videoStart', 2);
|
||||
assert.deepEqual(seen, [['param', 'b1', 'videoStart', 2], 'sync'],
|
||||
'the stage is written first, then the timeline model is resynced from it');
|
||||
globalThis.window = { }; // no timeline mounted must not throw
|
||||
Dock.prototype._param.call({ stage: { setParam(){} } }, 'b1', 'videoStart', 2);
|
||||
|
||||
// …and when the channel is KEYED that sync throws the typed value away (and the next evaluate
|
||||
// overwrites the stage too). It used to do so in silence, which mattered because a light preset
|
||||
// makes `exposure` keyed and "type it into the ambient inspector" was the documented way to fix a
|
||||
// mismatched level — it is now "⏺ key", and the field gave no sign of it.
|
||||
const keyedTl = { syncFromStage(){}, scene:{ entities:[
|
||||
{ id:'a1', tracks:{ params:[{ t:0, key:'exposure', value:1.6 }] } },
|
||||
{ id:'b1', tracks:{ params:[] } } ] } };
|
||||
assert.equal(keyedParam(keyedTl, 'a1', 'exposure'), true, 'a keyed param is recognised');
|
||||
assert.equal(keyedParam(keyedTl, 'a1', 'intensity'), false, 'per CHANNEL, not per entity');
|
||||
assert.equal(keyedParam(keyedTl, 'b1', 'exposure'), false, 'per ENTITY, not per name');
|
||||
assert.equal(keyedParam(null, 'a1', 'exposure'), false, 'no timeline → nothing is keyed');
|
||||
const said = [];
|
||||
const dockish = { stage:{ setParam(){} }, _toast(m){ said.push('dock:' + m); } };
|
||||
globalThis.window = { timeline: keyedTl };
|
||||
Dock.prototype._param.call(dockish, 'b1', 'exposure', 2);
|
||||
assert.deepEqual(said, [], 'an unkeyed field stays quiet');
|
||||
Dock.prototype._param.call(dockish, 'a1', 'exposure', 2);
|
||||
assert.equal(said.length, 1, 'a keyed one says so');
|
||||
assert.match(said[0], /exposure is keyed.*⏺ key/, `…naming the channel and the way out: ${said[0]}`);
|
||||
// …through tlui's toast when the timeline UI is mounted — the same banner that already carries
|
||||
// "<entity> is keyed — press ⏺ key…" for a gizmo drag, so there is one message, in one place.
|
||||
globalThis.window = { timeline: keyedTl, sgToast(m){ said.push('tlui:' + m); } };
|
||||
Dock.prototype._param.call(dockish, 'a1', 'exposure', 2);
|
||||
assert.match(said[1], /^tlui:exposure is keyed/, 'the timeline UI\'s channel wins when it exists');
|
||||
|
||||
console.log('stage_test: ALL PASS (fit s=' + f.s.toFixed(3) + ' → plate '
|
||||
+ (pw * f.s).toFixed(1) + '×' + (ph * f.s).toFixed(1) + 'm over a '
|
||||
+ f.fw.toFixed(1) + '×' + f.fh.toFixed(1) + 'm frame at ' + d + 'm)');
|
||||
+ f.fw.toFixed(1) + '×' + f.fh.toFixed(1) + 'm frame at ' + d + 'm; shadow box ±'
|
||||
+ one.toFixed(2) + 'm solo / ±' + pair.toFixed(2) + 'm for a two-hander; 1600×600 gate bars '
|
||||
+ wideView.left.toFixed(1) + 'px)');
|
||||
|
||||
@ -79,7 +79,10 @@ export class StageStub {
|
||||
getEntity(id) { return this._entities.get(id) || null; }
|
||||
entities() { return [...this._entities.values()]; }
|
||||
|
||||
select(id) { this._selected = id; this._selectCbs.forEach((cb) => cb(id)); }
|
||||
// honest-stub: the real Stage hands the ENTITY (or null) to onSelect listeners,
|
||||
// not the id (stage.js `select()`). The stub used to pass the id, which would
|
||||
// have let an outliner that reads `en.id` pass here and break live.
|
||||
select(id) { this._selected = id; this._selectCbs.forEach((cb) => cb(id ? this.getEntity(id) : null)); }
|
||||
onSelect(cb) { this._selectCbs.push(cb); }
|
||||
onChange(cb) { this._changeCbs.push(cb); }
|
||||
_fireChange(e) { this._changeCbs.forEach((cb) => cb(e)); }
|
||||
@ -112,6 +115,7 @@ export class StageStub {
|
||||
}
|
||||
|
||||
setActiveCamera(id) { this._active = id; this.applied.push({ type: 'camera', id }); console.debug('stub.setActiveCamera', id); }
|
||||
activeCamera() { return this._active || null; } // real Stage: stage.js `activeCamera()` (entity id, null = orbit cam)
|
||||
renderActiveCamera(_canvas) { /* noop in stub */ }
|
||||
|
||||
// M4-B viseme seam (real Stage: Lane A). Entities may carry a `visemes` array.
|
||||
|
||||
@ -29,9 +29,12 @@ header .tag{font-family:var(--mono);font-size:10px;color:var(--teal);letter-spac
|
||||
|
||||
/* dock */
|
||||
.tabs{display:flex;background:var(--bg2);border:1px solid var(--line2);border-radius:7px;overflow:hidden;margin-bottom:10px}
|
||||
.tabs button{flex:1;background:transparent;border:0;color:var(--mut);padding:7px 6px;font-family:var(--mono);
|
||||
.tabs button{flex:1;background:transparent;border:0;color:var(--mut);padding:6px 4px;font-family:var(--mono);
|
||||
font-size:10.5px;cursor:pointer;letter-spacing:.04em}
|
||||
.tabs button.on{background:var(--teal);color:#0c0f12;font-weight:700}
|
||||
/* item count under each tab name — an empty category is visible before you click it */
|
||||
.tabs button i{display:block;font-style:normal;font-size:9px;line-height:1.4;color:var(--teal);opacity:.8}
|
||||
.tabs button.on i{color:#0c0f12;opacity:.7}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px}
|
||||
.card{background:var(--bg2);border:1px solid var(--line);border-radius:9px;overflow:hidden;cursor:pointer}
|
||||
.card:hover{border-color:var(--teal)}
|
||||
@ -65,6 +68,10 @@ button.wide:hover{border-color:var(--teal)}
|
||||
.viseme{font-family:var(--mono);font-size:10.5px;color:var(--teal);background:var(--bg2);
|
||||
border:1px solid var(--line2);border-radius:6px;padding:6px 8px;margin:8px 0 0;word-break:break-word}
|
||||
.viseme.off{color:var(--mut);opacity:.6}
|
||||
/* muted inspector line — stands in for controls that would silently do nothing */
|
||||
.inote{font-family:var(--mono);font-size:10px;color:var(--mut);line-height:1.5;margin:7px 0;
|
||||
border-left:2px solid var(--line2);padding-left:7px}
|
||||
.inote.bad{color:var(--bad);border-left-color:var(--bad);word-break:break-word}
|
||||
|
||||
/* drop + toast */
|
||||
body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
@ -77,6 +84,26 @@ body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align
|
||||
background:#000;cursor:pointer;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
|
||||
.pip:hover{border-color:var(--ink)}
|
||||
|
||||
/* ⬚ FRAME GUIDE — DOM overlay masking the viewport to the delivery aspect (from tlui's render
|
||||
size), plus rule-of-thirds + 90% action-safe hairlines. z BELOW the PiP so the PiP stays
|
||||
clickable; pointer-events:none throughout; pure DOM, so it can never appear in a captured frame.
|
||||
.over = the delivery frame is WIDER than the viewport (three keeps the vertical fov fixed, so a
|
||||
narrow viewport shows a CROP of the render, not a letterbox): no bar can express that, say it. */
|
||||
.frameguide{position:absolute;inset:0;pointer-events:none;z-index:3;overflow:hidden}
|
||||
.frameguide i{position:absolute;display:block}
|
||||
.frameguide .fgb{background:#0a0c0e}
|
||||
.frameguide .fgb.t{top:0;left:0;right:0} .frameguide .fgb.b{bottom:0;left:0;right:0}
|
||||
.frameguide .fgb.l{top:0;bottom:0;left:0} .frameguide .fgb.r{top:0;bottom:0;right:0}
|
||||
.frameguide .fgv{top:0;bottom:0;width:1px;background:rgba(255,255,255,.25)}
|
||||
.frameguide .fgh{left:0;right:0;height:1px;background:rgba(255,255,255,.25)}
|
||||
.frameguide .fgsafe{inset:5%;border:1px solid rgba(255,255,255,.25)}
|
||||
/* overscan: the gate (= the encoded frame) now runs OFF both sides of the window and is clipped,
|
||||
so the dashed "you are seeing a crop" edges + the note sit on the guide itself — the window
|
||||
edges are where the crop happens, and the gate's own edges are not on screen to be dashed. */
|
||||
.frameguide.over{border-left:1px dashed rgba(255,186,84,.6);border-right:1px dashed rgba(255,186,84,.6)}
|
||||
.frameguide.over::after{content:'⟷ the render is wider than this viewport — its sides are off-screen';
|
||||
position:absolute;left:8px;top:6px;font-family:var(--mono);font-size:9.5px;color:rgba(255,186,84,.8)}
|
||||
|
||||
/* dock "add rig" toolbar (cameras + lights have no asset file) */
|
||||
.addbar{display:flex;gap:6px;margin-bottom:10px}
|
||||
.addbar button{flex:1;background:var(--panel);border:1px solid var(--line2);color:var(--ink);border-radius:7px;
|
||||
|
||||
@ -87,28 +87,32 @@ export class Timeline {
|
||||
this._qcache = new WeakMap(); // key obj -> cached quat (keeps keys pure for round-trip)
|
||||
this._clipActions = new Map(); // block obj -> {action, clip}
|
||||
this._activeCam = undefined; // last camera pushed (avoid re-spamming setActiveCamera)
|
||||
this._drove = new Map(); // "id::key" -> the param value THIS timeline last wrote (see syncFromStage)
|
||||
this.undoStack = [];
|
||||
// Snap config lives on the MODEL (tlui mirrors its checkbox + select into it)
|
||||
// so model-level ops — a camera move arriving on scenegod:direct — can land
|
||||
// on the beat exactly like a mouse drag does. UI writes, model reads.
|
||||
this.snap = { on: true, step: 'frame' }; // step: 'frame' | seconds | 'beat' | 'bar'
|
||||
|
||||
// Set by tlui._barBusySet for BOTH render paths — see _locked.
|
||||
this.locked = false;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('scenegod:capturekey', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
if (id) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' });
|
||||
if (id && !this._locked('⏺ key')) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' });
|
||||
});
|
||||
// Lane A dock drops a clip on a character → add a block at the playhead.
|
||||
// REVIEW-FIX: this is fire-and-forget from an event handler, so a rejected
|
||||
// promise vanished — a non-mixamo rig (CC_Base_*) failed the retarget and
|
||||
// the drop just silently did nothing. Surface every failure.
|
||||
window.addEventListener('scenegod:clipdrop', (e) => {
|
||||
if (e.detail && e.detail.id) this.addClipDrop(e.detail).catch((err) => this._fail('clip', err));
|
||||
if (e.detail && e.detail.id && !this._locked('clip drop')) this.addClipDrop(e.detail).catch((err) => this._fail('clip', err));
|
||||
});
|
||||
// M5 DIRECTOR MODE: Lane A presets dispatch ops → normal keys/cuts at
|
||||
// the playhead (contract: lanes/A §M5.3 / PLAN §5 M5).
|
||||
window.addEventListener('scenegod:direct', (e) => {
|
||||
if (e.detail && e.detail.op) {
|
||||
if (e.detail && e.detail.op && !this._locked(`director "${e.detail.op}"`)) {
|
||||
try { this.applyDirect(e.detail); } catch (err) { this._fail('direct', err); }
|
||||
}
|
||||
});
|
||||
@ -134,6 +138,21 @@ export class Timeline {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// REVIEW-FIX: a capture loop OWNS this clock — ⏺ Render and 🎬 Render film both
|
||||
// walk `step(frame)` while grabbing pixels. tlui takes the scene bar and the
|
||||
// track canvas out for the duration, but it can only disable controls INSIDE
|
||||
// `$bar`: Lane A's DIRECT panel (presets.js dispatches `scenegod:direct`) and
|
||||
// the dock sit outside it and mutate the scene by dispatching straight at these
|
||||
// listeners. A shot preset clicked mid-capture adds transform/param keys and a
|
||||
// camera cut at the playhead the stepper is currently walking, so the frames
|
||||
// already shot and the frames still to come come from two different edits.
|
||||
// `locked` is set by tlui._barBusySet, the one choke point both renders use.
|
||||
_locked(what) {
|
||||
if (!this.locked) return false;
|
||||
this._say(`${what}: a render is capturing this timeline — try again when it finishes`);
|
||||
return true;
|
||||
}
|
||||
|
||||
_mirror(en) {
|
||||
if (!en || !en.id) return;
|
||||
const list = this.scene.entities;
|
||||
@ -150,6 +169,92 @@ export class Timeline {
|
||||
transform: this.stage.entityTransform(en.id),
|
||||
tracks: { transform: [], params: [], clips: [] },
|
||||
});
|
||||
// CAMERAS GO LIVE AT A CUT. dock._addRig sets a new camera active so the PiP
|
||||
// lights up — and the very next evaluate() put it straight back on the orbit
|
||||
// cam, because an empty cut list means "no camera". The first camera in a
|
||||
// scene therefore gets a cut at 0: it is the only reading of "I added a
|
||||
// camera" that isn't immediately undone by the edit.
|
||||
// Only for a LIVE add — during applyScene the mirrored copy is thrown away by
|
||||
// load() a moment later, and a toast on every scene load would be noise.
|
||||
if (en.kind === 'camera' && !this._applying && this.scene.cameraCuts.length === 0) {
|
||||
this.addCut(0, en.id);
|
||||
this._say(`cut at 0s → ${en.label || en.id} · cameras go live at a cut`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write the stage back into the model: the transform you dragged with the gizmo
|
||||
// and the params you dialled in the inspector are LIVE on the stage but were
|
||||
// never copied home — _mirror only ever snapshots an entity once, at drop time,
|
||||
// so save() serialised the pose the prop had when it landed. Called at the top
|
||||
// of a save.
|
||||
// ONLY `transform` and `params` are taken: StageStub.captureState() clones whole
|
||||
// entity objects (tracks included) and a future Stage might too — the timeline
|
||||
// owns tracks, and a stale copy of them would eat live keys. Never adds an
|
||||
// entity (that is _mirror's job) and never removes one.
|
||||
//
|
||||
// REVIEW-FIX (blocker): a KEYED channel is never synced. `Stage.captureState()`
|
||||
// reports `entityTransform(id)` — i.e. the pose the TIMELINE itself just wrote
|
||||
// through setTransform/setParam on the last evaluate — so for a keyed entity
|
||||
// this was copying the timeline's own output back over the source of truth.
|
||||
// PLAN §4.1 defines `transform` as the "rest pose (used when no keys)": with
|
||||
// the old code, scrubbing to t=5 and saving wrote the t=5 pose as the rest
|
||||
// pose, scrubbing to t=2.5 and saving wrote a different one (same scene, two
|
||||
// saves, two different files), and deleting the keys later left the entity in
|
||||
// a pose nobody authored. Per CHANNEL, not per entity: an unkeyed param on a
|
||||
// keyed light still syncs.
|
||||
syncFromStage() {
|
||||
if (!this.stage.captureState) return 0;
|
||||
let n = 0;
|
||||
for (const rec of (this.stage.captureState() || [])) {
|
||||
const e = this.scene.entities.find((x) => x.id === rec.id);
|
||||
if (!e || !rec) continue; // on stage but not in the model → _mirror's problem
|
||||
const tracks = e.tracks || {};
|
||||
let touched = false;
|
||||
if (rec.params) {
|
||||
const authored = e.params || {};
|
||||
const next = structuredClone(rec.params);
|
||||
const edited = [];
|
||||
for (const k of new Set((tracks.params || []).map((p) => p.key))) {
|
||||
// A keyed param name is DRIVEN by the timeline: the live stage value is
|
||||
// this tick's lerp, so copying it home is how the playhead position ends
|
||||
// up baked into the file (the session-11 blocker). Keep the authored
|
||||
// rest value…
|
||||
if (Object.prototype.hasOwnProperty.call(authored, k)) next[k] = structuredClone(authored[k]);
|
||||
else {
|
||||
// …and when there is none, use the FIRST key's value rather than
|
||||
// deleting the param outright (REVIEW-FIX). Deleting it meant a keyed
|
||||
// camera saved with no `fov` in `params` at all — nothing to fall back
|
||||
// to if the keys are later removed. The first key is what `evaluate`
|
||||
// itself holds for t before it, so this is still authored, still
|
||||
// playhead-independent, and invents nothing.
|
||||
const first = (tracks.params || []).filter((p) => p.key === k)
|
||||
.reduce((a, p) => (a === null || p.t < a.t ? p : a), null);
|
||||
if (first) next[k] = structuredClone(first.value); else delete next[k];
|
||||
}
|
||||
// An inspector edit to a keyed param can only ever survive until the next
|
||||
// evaluate — dock.js `_param` calls straight in here, so this is where it
|
||||
// dies. It used to die SILENTLY. `_drove` is what the timeline itself last
|
||||
// wrote, so a difference means a human changed it.
|
||||
const drove = this._drove.get(e.id + '::' + k);
|
||||
if (drove !== undefined && rec.params[k] !== undefined && rec.params[k] !== drove
|
||||
&& next[k] !== rec.params[k]) edited.push(k);
|
||||
}
|
||||
if (edited.length) {
|
||||
this._say(`${e.label || e.id}: ${edited.join(', ')} ${edited.length === 1 ? 'is' : 'are'} keyed — `
|
||||
+ 'the timeline drives it, so this edit will not stick. Double-click the ↳ params lane to key it here.');
|
||||
}
|
||||
e.params = next; touched = true;
|
||||
}
|
||||
// A frustum-fitted plate is placed BY THE LENS, not by the user (stage.js
|
||||
// `setTransform` early-returns into `_fitBackdrop` for exactly this reason)
|
||||
// — its wrapper transform is a render artefact of the current camera. Keep
|
||||
// the params, leave the authored transform alone; the plate refits on load.
|
||||
const fitted = e.params && e.params.fit === 'frustum';
|
||||
const keyed = ((tracks.transform || []).length > 0);
|
||||
if (rec.transform && !fitted && !keyed) { e.transform = structuredClone(rec.transform); touched = true; }
|
||||
if (touched) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
get fps() { return this.scene.fps; }
|
||||
@ -174,7 +279,32 @@ export class Timeline {
|
||||
return this;
|
||||
}
|
||||
onLoad(cb) { this._loadCbs.push(cb); }
|
||||
toJSON() { return structuredClone(this.scene); }
|
||||
// A cut naming an entity that is no longer a camera makes the scene UNSAVEABLE
|
||||
// (server validate_scene 422s on it). Three rounds of review showed you cannot
|
||||
// win that by keeping every undo history clean: deleting a camera spans Stage
|
||||
// (no undo) and Timeline (undo), so ANY older entry — a removeCut, or an addCut
|
||||
// restoring the `prev` it replaced — can resurrect a cut for a camera that is
|
||||
// gone. So enforce the invariant where it actually has to hold instead: nothing
|
||||
// dangling ever leaves toJSON(). Cheap, history-independent, and the same rule
|
||||
// the server applies. ponytail: prune, don't reconcile.
|
||||
liveCameraIds() {
|
||||
return new Set((this.scene.entities || []).filter((e) => e.kind === 'camera').map((e) => e.id));
|
||||
}
|
||||
danglingCuts() {
|
||||
const live = this.liveCameraIds();
|
||||
return (this.scene.cameraCuts || []).filter((c) => !live.has(c.camera));
|
||||
}
|
||||
toJSON() {
|
||||
const out = structuredClone(this.scene);
|
||||
const live = this.liveCameraIds();
|
||||
const kept = (out.cameraCuts || []).filter((c) => live.has(c.camera));
|
||||
if (kept.length !== (out.cameraCuts || []).length) {
|
||||
const n = out.cameraCuts.length - kept.length;
|
||||
out.cameraCuts = kept;
|
||||
this._say(`dropped ${n} camera cut${n > 1 ? 's' : ''} pointing at a deleted camera`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apply a whole scene JSON: stage first (builds the entity objects), then the
|
||||
// tracks, then clip preload. Load and "new from template" share this so the
|
||||
@ -184,7 +314,12 @@ export class Timeline {
|
||||
async applyScene(json) {
|
||||
const scene = structuredClone(json);
|
||||
delete scene.template;
|
||||
if (this.stage.applyState) await this.stage.applyState(scene);
|
||||
// _applying: the stage adds fire onChange → _mirror, into the scene we are
|
||||
// about to REPLACE. Harmless, except for side effects meant for a live add
|
||||
// (the first-camera cut, the UI's "this entity is keyed" toast).
|
||||
this._applying = true;
|
||||
try { if (this.stage.applyState) await this.stage.applyState(scene); }
|
||||
finally { this._applying = false; }
|
||||
this.load(scene); // fires onLoad → UI rebuilds rows + scene bar
|
||||
await this.preload();
|
||||
return scene;
|
||||
@ -364,6 +499,10 @@ export class Timeline {
|
||||
: lerp(k0.value, k1.value, eu);
|
||||
}
|
||||
this.stage.setParam(e.id, name, val);
|
||||
// Remember what WE put there. syncFromStage uses it to tell "the live value
|
||||
// is just my own lerp" (leave it alone, silently) apart from "somebody typed
|
||||
// a new one into the inspector" (say that it is about to be discarded).
|
||||
this._drove.set(e.id + '::' + name, val);
|
||||
}
|
||||
}
|
||||
|
||||
@ -402,13 +541,27 @@ export class Timeline {
|
||||
// scene has no cut yet, i.e. the director cam). Public because a move needs to
|
||||
// say when it is animating a camera nothing cuts to.
|
||||
cutCameraAt(t) {
|
||||
// Skip cuts whose camera no longer exists — the same dangling cuts toJSON()
|
||||
// prunes. Playback must not jump to a dead id just because undo resurrected
|
||||
// a cut for it; it holds the last LIVE camera instead.
|
||||
const live = this.liveCameraIds();
|
||||
let cam = null;
|
||||
for (const c of this.cameraCuts) { if (c.t <= t) cam = c.camera; else break; }
|
||||
for (const c of this.cameraCuts) { if (c.t <= t) { if (live.has(c.camera)) cam = c.camera; } else break; }
|
||||
return cam;
|
||||
}
|
||||
// Compare against what the STAGE is actually showing, not a cached value: the
|
||||
// dock's ◉ button, "+ camera" and presets' move op all change the active camera
|
||||
// behind the timeline's back, which desynced `_activeCam` permanently — after
|
||||
// that the viewport showed a camera the edit never cuts to and no amount of
|
||||
// scrubbing put it right. `_activeCam` stays as the fallback for a Stage that
|
||||
// predates the accessor. (Deliberately NOT `?? this._activeCam`: a stage sitting
|
||||
// on the director cam reports null, and falling back to a stale id there is the
|
||||
// very desync this fixes.)
|
||||
_evalCameraCuts(t) {
|
||||
const cam = this.cutCameraAt(t);
|
||||
if (cam !== this._activeCam) { this._activeCam = cam; this.stage.setActiveCamera(cam); }
|
||||
const cur = this.stage.activeCamera ? this.stage.activeCamera() : this._activeCam;
|
||||
this._activeCam = cam;
|
||||
if (cam !== cur) this.stage.setActiveCamera(cam);
|
||||
}
|
||||
|
||||
// ---- keyframe / block mutators (keep sorted, push inverse for undo) ----
|
||||
@ -465,14 +618,40 @@ export class Timeline {
|
||||
if (outV != null) block.out = outV;
|
||||
this.undoStack.push({ undo: () => { block.in = oldIn; block.out = oldOut; } });
|
||||
}
|
||||
// A cut REPLACES any cut within half a frame of it, exactly like addKey replaces
|
||||
// a key at the same t. Every DIRECT shot op ends in addCut(playhead, camId), so
|
||||
// "select subject → + angle → + angle → + angle" used to stack three cuts at
|
||||
// t=0, only the last of which did anything — all drawn on top of each other and
|
||||
// all riding into the saved scene. Half a frame is autoCutPlan's own rule.
|
||||
addCut(t, cameraId) {
|
||||
const cut = { t, camera: cameraId };
|
||||
this.scene.cameraCuts.push(cut);
|
||||
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
|
||||
const arr = this.scene.cameraCuts;
|
||||
const eps = 0.5 / (this.fps || 30);
|
||||
const i = arr.findIndex((c) => Math.abs(c.t - t) <= eps);
|
||||
const prev = i >= 0 ? arr[i] : null;
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
arr.push(cut);
|
||||
arr.sort((a, b) => a.t - b.t);
|
||||
this._activeCam = undefined; // force re-eval of active cam
|
||||
this.undoStack.push({ undo: () => { const i = this.scene.cameraCuts.indexOf(cut); if (i >= 0) this.scene.cameraCuts.splice(i, 1); } });
|
||||
this.undoStack.push({ undo: () => { // one entry: the add AND the replacement
|
||||
const j = arr.indexOf(cut); if (j >= 0) arr.splice(j, 1);
|
||||
if (prev) { arr.push(prev); arr.sort((a, b) => a.t - b.t); }
|
||||
this._activeCam = undefined;
|
||||
} });
|
||||
return cut;
|
||||
}
|
||||
// Dragging a cut used to mutate cut.t in place, so Ctrl+Z after moving one
|
||||
// silently reverted an unrelated earlier edit instead. The drag's per-mousemove
|
||||
// entries are collapsed into one at drop (tlui `_up` → collapseUndo).
|
||||
moveCut(cut, t) {
|
||||
const old = cut.t;
|
||||
cut.t = t;
|
||||
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
|
||||
this._activeCam = undefined;
|
||||
this.undoStack.push({ undo: () => {
|
||||
cut.t = old; this.scene.cameraCuts.sort((a, b) => a.t - b.t); this._activeCam = undefined;
|
||||
} });
|
||||
}
|
||||
removeCut(cut) {
|
||||
const i = this.scene.cameraCuts.indexOf(cut);
|
||||
if (i < 0) return;
|
||||
@ -480,6 +659,21 @@ export class Timeline {
|
||||
this._activeCam = undefined;
|
||||
this.undoStack.push({ undo: () => { this.scene.cameraCuts.splice(i, 0, cut); this._activeCam = undefined; } });
|
||||
}
|
||||
// REVIEW-FIX (minor): two cuts within half a frame are indistinguishable on
|
||||
// screen and `cutCameraAt` silently ignores the earlier one, so dragging cut B
|
||||
// onto cut A produced a cut that renders as nothing and can only be deleted by
|
||||
// right-clicking the exact pixel. addCut has always replaced inside that window
|
||||
// — a drag now does the same, but at DROP: merging mid-drag would eat every cut
|
||||
// the pointer happened to pass over. Returns how many dead cuts it absorbed.
|
||||
settleCut(cut) {
|
||||
const arr = this.scene.cameraCuts;
|
||||
const eps = 0.5 / (this.fps || 30);
|
||||
const dead = arr.filter((c) => c !== cut && Math.abs(c.t - cut.t) <= eps);
|
||||
if (!dead.length) return 0;
|
||||
this.group(() => { for (const c of dead) this.removeCut(c); });
|
||||
return dead.length;
|
||||
}
|
||||
|
||||
undo() { const op = this.undoStack.pop(); if (op) op.undo(); this.evaluate(this.time); }
|
||||
|
||||
// Run fn (which may push many undo entries) and collapse everything it
|
||||
@ -487,13 +681,22 @@ export class Timeline {
|
||||
group(fn) {
|
||||
const n = this.undoStack.length;
|
||||
const r = fn();
|
||||
if (this.undoStack.length > n + 1) {
|
||||
const ops = this.undoStack.splice(n);
|
||||
this.undoStack.push({ undo: () => { for (let i = ops.length - 1; i >= 0; i--) ops[i].undo(); } });
|
||||
}
|
||||
this.collapseUndo(n);
|
||||
return r;
|
||||
}
|
||||
|
||||
// The same collapse for edits that span EVENTS rather than a function call: a
|
||||
// mouse drag calls moveKey/moveCut once per mousemove, so a 200px drag left ~50
|
||||
// entries and Ctrl+Z walked the cut back one mouse-frame at a time instead of
|
||||
// reverting the drag. tlui marks the stack depth on mousedown and calls this on
|
||||
// mouseup. No-op for 0 or 1 entries, so a click that moved nothing costs nothing.
|
||||
collapseUndo(mark) {
|
||||
if (!(mark >= 0) || this.undoStack.length <= mark + 1) return 0;
|
||||
const ops = this.undoStack.splice(mark);
|
||||
this.undoStack.push({ undo: () => { for (let i = ops.length - 1; i >= 0; i--) ops[i].undo(); } });
|
||||
return ops.length;
|
||||
}
|
||||
|
||||
// ---- M5 DIRECTOR MODE: scenegod:direct ops → keys/cuts at the playhead.
|
||||
// Accepts BOTH Lane A's live presets.js payloads (values spread at top level:
|
||||
// shot {camId, pos, rot, fov, …, entityIds:[camId, subjectId]}
|
||||
@ -707,7 +910,7 @@ export class Timeline {
|
||||
// the musical spine of an edit, not a UI preference. It is a single top-level
|
||||
// object, NOT per-audio-clip: one track sets the tempo of a music video, and
|
||||
// "which grid is the timeline snapping to" must have exactly one answer.
|
||||
// Legal by inspection: server.py:235 `validate_scene` only looks at version /
|
||||
// Legal by inspection: server.py's `validate_scene` only looks at version /
|
||||
// entities / cameraCuts, so an extra top-level key passes, and load()'s
|
||||
// structuredClone carries it through the lossless round-trip for free.
|
||||
//
|
||||
|
||||
@ -233,6 +233,19 @@ globalThis.window = new EventTarget();
|
||||
const dStage = new StageStub();
|
||||
const tlDir = new Timeline(dStage); // ctor: listener + _mirror wired
|
||||
await dStage.addEntity({ id: 'cam1', kind: 'camera', params: { fov: 45 } });
|
||||
// CAMERAS GO LIVE AT A CUT: the first camera added to a cutless scene gets one at
|
||||
// 0, or the next evaluate() drops the view straight back to the orbit cam.
|
||||
assert.deepEqual(tlDir.cameraCuts, [{ t: 0, camera: 'cam1' }], 'the first camera added goes live at t=0');
|
||||
tlDir.seek(0);
|
||||
assert.equal(dStage._active, 'cam1', 'and a seek does NOT snap the view back to the orbit cam');
|
||||
assert.equal(dStage.activeCamera(), 'cam1', 'stagestub exposes activeCamera() like the real Stage');
|
||||
// the edit wins over anything that changes the active camera behind our back
|
||||
dStage.setActiveCamera(null);
|
||||
tlDir.seek(0);
|
||||
assert.equal(dStage._active, 'cam1', 'a seek re-asserts the cut-derived camera after an out-of-band change');
|
||||
await dStage.addEntity({ id: 'cam9', kind: 'camera', params: { fov: 45 } });
|
||||
assert.equal(tlDir.cameraCuts.length, 1, 'a SECOND camera adds no cut — the edit is the user\'s from here');
|
||||
dStage.removeEntity('cam9');
|
||||
await dStage.addEntity({ id: 'sun', kind: 'light', params: { type: 'key', color: '#ffffff', intensity: 1 } });
|
||||
await dStage.addEntity({ id: 'lady', kind: 'character', label: 'lady' });
|
||||
tlDir.seek(3);
|
||||
@ -248,13 +261,14 @@ const camE = tlDir.scene.entities.find((x) => x.id === 'cam1');
|
||||
assert.equal(camE.tracks.transform.length, 1, 'shot lands one transform key');
|
||||
assert.ok(near(camE.tracks.transform[0].t, 3) && near(camE.tracks.transform[0].pos[2], 6), 'shot key at playhead with the preset pos');
|
||||
assert.ok(camE.tracks.params.some((k) => k.key === 'fov' && k.value === 35 && near(k.t, 3)), 'shot lands the fov param key');
|
||||
assert.deepEqual(tlDir.cameraCuts, [{ t: 3, camera: 'cam1' }], 'shot lands a cut to the camera');
|
||||
assert.deepEqual(tlDir.cameraCuts, [{ t: 0, camera: 'cam1' }, { t: 3, camera: 'cam1' }],
|
||||
'shot lands a cut to the camera (on top of the auto first cut)');
|
||||
assert.equal(dStage._active, 'cam1', 'shot re-evaluates: camera is live immediately');
|
||||
assert.equal(tlDir.undoStack.length, depth + 1, 'one shot op = one undo entry');
|
||||
tlDir.undo();
|
||||
assert.equal(camE.tracks.transform.length, 0, 'undo reverts the shot transform key');
|
||||
assert.equal(camE.tracks.params.length, 0, 'undo reverts the fov key');
|
||||
assert.equal(tlDir.cameraCuts.length, 0, 'undo reverts the cut');
|
||||
assert.deepEqual(tlDir.cameraCuts, [{ t: 0, camera: 'cam1' }], 'undo reverts the shot cut only');
|
||||
|
||||
// light: generic contract shape — entityId + params map at the playhead
|
||||
depth = tlDir.undoStack.length;
|
||||
@ -482,7 +496,8 @@ assert.ok(!('beatGrid' in tlRT.toJSON()) && tlRT.snapToGrid(3, 'beat') === null,
|
||||
// ---- M9-B2: FILM shot list — runtime must agree with the server ----------
|
||||
// tlui.js has no top-level DOM, so the pure bits are importable here (and this
|
||||
// import fails loudly the day that stops being true).
|
||||
const { filmRuntime } = await import('./tlui.js');
|
||||
const { filmRuntime, renderPlan, uploadEntities, saveMessage, rowFlags,
|
||||
renderRefusal, audioProblems, cutAction, LANE_BG, CUT_COLOR, TimelineUI } = await import('./tlui.js');
|
||||
const demoShots = [ // sequences/demo-two-shot.json
|
||||
{ scene: 'sync2-sunset', in: 0.0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
|
||||
{ scene: 'sync2-sunset', in: 4.5, out: 8.0, transition: 'cut' },
|
||||
@ -645,6 +660,11 @@ tlAC.undo(); tlAC.seek(0);
|
||||
|
||||
// 3 cameras cycle in scene order; a deselected camera is skipped
|
||||
await acStage.addEntity({ id: 'camC', kind: 'camera', params: {} }); // _mirror → scene.entities
|
||||
// the cut list happens to be empty at this instant, so _mirror ALSO puts camC
|
||||
// live at 0 ("cameras go live at a cut"). Take that one back off so the cycle
|
||||
// assertions below read only auto-cut's own output.
|
||||
assert.deepEqual(tlAC.cameraCuts, [{ t: 0, camera: 'camC' }], 'a camera added to a cutless scene goes live at 0');
|
||||
tlAC.removeCut(tlAC.cameraCuts[0]);
|
||||
assert.deepEqual(tlAC.cameraIds(), ['camA', 'camB', 'camC'], 'a dock-added camera joins the cycle');
|
||||
tlAC.autoCut({ cadence: 1 });
|
||||
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camB', 'camC', 'camA', 'camB', 'camC', 'camA', 'camB'],
|
||||
@ -719,6 +739,10 @@ globalThis.window = new EventTarget();
|
||||
const mvStage = new StageStub();
|
||||
const tlMv = new Timeline(mvStage); // ctor: scenegod:direct listener + _mirror
|
||||
await mvStage.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', params: { fov: 37.85 } });
|
||||
// the first camera goes live at 0 (asserted in the DIRECT block above); this
|
||||
// block is about MOVES, so take that cut back off and test them in isolation.
|
||||
assert.deepEqual(tlMv.cameraCuts, [{ t: 0, camera: 'cam2' }], 'the added camera went live at 0');
|
||||
tlMv.removeCut(tlMv.cameraCuts[0]);
|
||||
await mvStage.addEntity({ id: 'man', kind: 'character', label: 'man' });
|
||||
tlMv.setDuration(16);
|
||||
tlMv.seek(2);
|
||||
@ -902,4 +926,785 @@ if (mvJson) {
|
||||
console.log(' (music-video.json: bar-snapped move on cam2, 3.2907 → 8.9146 = 3 bars)');
|
||||
}
|
||||
|
||||
// ---- ⏺ RENDER: the finalRender() plan for the scene you are looking at ------
|
||||
// renderPlan is the whole click handler minus the await — pinning it here pins
|
||||
// what the button does. (finalRender itself is Lane C's and needs a WebGL canvas.)
|
||||
const rpStage = new StageStub();
|
||||
const tlRP = new Timeline(rpStage);
|
||||
tlRP.load({ version: 1, name: 'street', fps: 30, duration: 20, entities: [], cameraCuts: [],
|
||||
audio: [{ path: 'audio/vo1.wav', start: 0.5, gain: 0.8 }] });
|
||||
const rp = renderPlan(tlRP, '1280x720');
|
||||
assert.equal(rp.width, 1280, 'the size select drives the width');
|
||||
assert.equal(rp.height, 720, 'and the height');
|
||||
assert.equal(rp.fps, 30, 'fps comes from the scene, never the picker');
|
||||
assert.equal(rp.name, 'street', 'the render is named after the scene');
|
||||
assert.equal(rp.total, 600, 'a 20s scene at 30fps is 600 frames');
|
||||
assert.deepEqual(rp.audio, tlRP.scene.audio, 'scene audio[] is passed through to the mux');
|
||||
assert.equal(rp.audio[0].gain, 0.8, 'gain and all');
|
||||
assert.deepEqual(renderPlan(tlRP, 'not-a-size'), { ...rp, width: 1920, height: 1080 },
|
||||
'an unparseable size falls back to 1080p rather than rendering 0x0');
|
||||
assert.equal(renderPlan(tlRP, '1920×1080').width, 1920, 'the ×-glyph label form parses too');
|
||||
const tlEmpty = new Timeline(new StageStub());
|
||||
tlEmpty.load({ version: 1, fps: 30, duration: 2, entities: [], cameraCuts: [], audio: [] });
|
||||
assert.equal(renderPlan(tlEmpty, '854x480').name, 'untitled', 'an unnamed scene still renders');
|
||||
assert.equal(renderPlan(tlEmpty, '854x480').total, 60, 'and an EMPTY scene is a valid black plate');
|
||||
const tlLong = new Timeline(new StageStub());
|
||||
tlLong.load({ version: 1, fps: 30, duration: 700, entities: [], cameraCuts: [], audio: [] });
|
||||
assert.throws(() => renderPlan(tlLong, '854x480'), /max 20000/,
|
||||
'a render past the server frame cap is refused BEFORE 20000 PNGs are uploaded');
|
||||
assert.throws(() => renderPlan(tlLong, '854x480'), /21000 frames/, 'and says how long it actually is');
|
||||
|
||||
// ---- SAVE the stage you actually built (syncFromStage) ----------------------
|
||||
// _mirror snapshots an entity's transform+params ONCE, at drop time, and then
|
||||
// early-returns forever. So a prop dragged into place with W, a key light dialled
|
||||
// to 0.8 and a backdrop's fitDist all reverted to their drop-time values on the
|
||||
// next save/reload. Only keyframed entities survived — and nobody keyframes set
|
||||
// dressing. FAILS on the old code (no syncFromStage at all).
|
||||
const syScene = structuredClone(scene);
|
||||
syScene.entities.push({
|
||||
id: 'plate', kind: 'backdrop', label: 'street',
|
||||
source: { type: 'assets', path: 'backdrops/street.jpg' },
|
||||
params: { mode: 'plane', fit: 'frustum', fitDist: 12 },
|
||||
transform: { pos: [0, 3, -9], rot: [0, 0, 0], scale: 1 }, tracks: {},
|
||||
});
|
||||
syScene.entities.push({ // set dressing: no keys, ever
|
||||
id: 'crate', kind: 'prop', label: 'crate',
|
||||
source: { type: 'assets', path: 'props/crate.glb' },
|
||||
params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, tracks: {},
|
||||
});
|
||||
const syStage = new StageStub();
|
||||
const tlSy = new Timeline(syStage);
|
||||
await tlSy.applyScene(syScene);
|
||||
const plateBefore = structuredClone(tlSy.scene.entities.find((e) => e.id === 'plate').transform);
|
||||
syStage.setTransform('crate', { pos: [-3, 0, 1], rot: [0, 0, 0], scale: 1 }); // gizmo drag
|
||||
syStage.setParam('cam1', 'fov', 28); // inspector edit
|
||||
syStage.setParam('plate', 'fitDist', 20);
|
||||
syStage.setTransform('plate', { pos: [0, 99, -99], rot: [0, 0, 0], scale: 4 }); // the LENS did this
|
||||
const synced = tlSy.syncFromStage();
|
||||
assert.equal(synced, 6, 'every entity on the stage is merged home');
|
||||
const outSy = tlSy.toJSON();
|
||||
assert.deepEqual(outSy.entities.find((e) => e.id === 'crate').transform.pos, [-3, 0, 1],
|
||||
'the gizmo pose of an UNKEYED entity is what gets saved');
|
||||
assert.equal(outSy.entities.find((e) => e.id === 'cam1').params.fov, 28, 'and the inspector fov');
|
||||
const e1Sy = outSy.entities.find((e) => e.id === 'e1');
|
||||
assert.equal(e1Sy.tracks.transform.length, 2, 'the entity keeps BOTH its original keys');
|
||||
assert.deepEqual(e1Sy.tracks.transform.map((k) => k.t), [0, 2], 'exactly where they were');
|
||||
assert.deepEqual(e1Sy.tracks.clips[0].path, 'animations/walk.fbx', 'and its clip block');
|
||||
const plateSy = outSy.entities.find((e) => e.id === 'plate');
|
||||
assert.equal(plateSy.params.fitDist, 20, 'a frustum plate DOES take its params');
|
||||
assert.deepEqual(plateSy.transform, plateBefore,
|
||||
'but NOT its transform — that wrapper is owned by the lens (stage.js `setTransform`), not the user');
|
||||
// an entity that is on the stage but unknown to the model is ignored here — it
|
||||
// arrives via _mirror, and syncFromStage must never add or remove anything.
|
||||
const strayAt = tlSy.scene.entities.findIndex((e) => e.id === 'plate');
|
||||
const stray = tlSy.scene.entities.splice(strayAt, 1)[0];
|
||||
assert.equal(tlSy.syncFromStage(), 5, 'the unknown stage entity is skipped, not adopted');
|
||||
assert.equal(tlSy.scene.entities.length, 5, 'and nothing was added');
|
||||
tlSy.scene.entities.splice(strayAt, 0, stray);
|
||||
// keys still win at evaluate time, so writing the current pose home is lossless
|
||||
tlSy.seek(1);
|
||||
const roundTrip = new Timeline(new StageStub());
|
||||
roundTrip.load(tlSy.toJSON());
|
||||
assert.deepEqual(roundTrip.toJSON(), tlSy.toJSON(), 'a synced scene still round-trips losslessly');
|
||||
|
||||
// REVIEW-FIX (blocker): a save must NOT depend on where the playhead is.
|
||||
// Stage.captureState() reports entityTransform(id) — the pose the TIMELINE just
|
||||
// wrote via setTransform on the last evaluate — so syncing a KEYED channel copied
|
||||
// the timeline's own output back over the authored rest pose (PLAN §4.1: "rest
|
||||
// pose (used when no keys)"). Old behaviour: scrub to 2 → save → e1.transform.pos
|
||||
// becomes [10,0,0]; scrub to 1 → save → [5,0,0]. Same scene, two saves, two files,
|
||||
// and deleting the keys left the character somewhere nobody authored.
|
||||
const pdStage = new StageStub();
|
||||
const tlPD = new Timeline(pdStage);
|
||||
await tlPD.applyScene(structuredClone(syScene));
|
||||
const authoredPos = structuredClone(tlPD.scene.entities.find((e) => e.id === 'e1').transform.pos);
|
||||
const authoredSun = tlPD.scene.entities.find((e) => e.id === 'e2').params.intensity;
|
||||
tlPD.seek(2); tlPD.syncFromStage();
|
||||
const atEnd = structuredClone(tlPD.toJSON());
|
||||
tlPD.seek(1); tlPD.syncFromStage();
|
||||
const atMid = tlPD.toJSON();
|
||||
assert.deepEqual(atMid.entities.find((e) => e.id === 'e1').transform.pos, authoredPos,
|
||||
'a keyed entity keeps the AUTHORED rest pose, whatever the playhead shows');
|
||||
assert.equal(atMid.entities.find((e) => e.id === 'e2').params.intensity, authoredSun,
|
||||
'and a keyed param keeps its authored rest value (the live value is this tick’s lerp)');
|
||||
assert.deepEqual(atMid, atEnd, 'two saves of the same scene at different playhead times are byte-identical');
|
||||
// per CHANNEL, not per entity: the sun's intensity is keyed, its colour is not.
|
||||
pdStage.setParam('e2', 'color', '#ff8800');
|
||||
pdStage.setParam('e2', 'intensity', 999); // as if the inspector fought the key
|
||||
tlPD.syncFromStage();
|
||||
const sunPD = tlPD.toJSON().entities.find((e) => e.id === 'e2');
|
||||
assert.equal(sunPD.params.color, '#ff8800', 'an UNKEYED param on a keyed entity still syncs');
|
||||
assert.equal(sunPD.params.intensity, authoredSun, 'the keyed one does not');
|
||||
// and the unkeyed neighbours are still synced on the very same pass
|
||||
assert.deepEqual(tlPD.toJSON().entities.find((e) => e.id === 'crate').transform.pos, [0, 0, 0],
|
||||
'an untouched unkeyed prop keeps its own pose');
|
||||
|
||||
// ---- SAVE/LOAD stop lying: the two pure seams -------------------------------
|
||||
assert.deepEqual(uploadEntities({ entities: [
|
||||
{ id: 'a', label: 'lady', source: { type: 'upload' } },
|
||||
{ id: 'b', label: 'street', source: { type: 'upload' } },
|
||||
{ id: 'c', label: 'cam', source: { type: 'none' } },
|
||||
{ id: 'd', label: 'crate', source: { type: 'assets', path: 'props/crate.glb' } },
|
||||
] }), ['lady', 'street'], 'both dropped files are found, by label');
|
||||
assert.deepEqual(uploadEntities(scene), [], 'a clean scene warns about nothing');
|
||||
assert.deepEqual(uploadEntities(null), [], 'and a missing scene does not throw');
|
||||
assert.deepEqual(uploadEntities({ entities: [{ id: 'x', source: { type: 'upload' } }] }), ['x'],
|
||||
'an unlabelled upload falls back to its id');
|
||||
|
||||
const failMsg = saveMessage({ ok: false, status: 422, errors: ['cameraCut references non-camera entity e2'] });
|
||||
assert.ok(failMsg.includes('NOT saved'), 'a refused save says NOT saved');
|
||||
assert.ok(failMsg.includes('cameraCut references non-camera entity e2'), 'and quotes the server verbatim');
|
||||
assert.ok(saveMessage({ ok: false, status: 422, errors: ['a', 'b'] }).includes('a · b'), 'multiple errors are joined');
|
||||
assert.ok(saveMessage({ ok: false, status: 500 }).includes('server said 500'), 'a bodyless failure still names the status');
|
||||
const netMsg = saveMessage({ ok: false, name: 'x' }); // no status = the fetch itself threw
|
||||
assert.ok(netMsg.includes('this browser only'), 'only a REAL network failure claims a local copy');
|
||||
assert.ok(!netMsg.includes('NOT saved'), 'and it is not reported as a refusal');
|
||||
const okMsg = saveMessage({ ok: true, name: 'street-scene', count: 7 });
|
||||
assert.ok(okMsg.includes('saved') && okMsg.includes('street-scene') && okMsg.includes('7'),
|
||||
'a good save names the scene and the entity count');
|
||||
assert.ok(saveMessage({ ok: true, name: 'x', count: 1 }).includes('1 entity'), 'and counts in English');
|
||||
|
||||
// ---- the Cameras lane: replace, not stack; undo-able; live at a cut ---------
|
||||
const cutStage = new StageStub();
|
||||
const tlCut = new Timeline(cutStage);
|
||||
tlCut.load({ version: 1, fps: 30, duration: 10, cameraCuts: [], audio: [],
|
||||
entities: [{ id: 'c1', kind: 'camera', label: 'wide', tracks: {} },
|
||||
{ id: 'c2', kind: 'camera', label: 'tight', tracks: {} }] });
|
||||
tlCut.addCut(0, 'c1');
|
||||
tlCut.addCut(0.01, 'c2'); // 0.3 frames later = the same instant
|
||||
assert.equal(tlCut.cameraCuts.length, 1, 'a cut within half a frame REPLACES rather than stacking');
|
||||
assert.equal(tlCut.cameraCuts[0].camera, 'c2', 'the replacement wins');
|
||||
assert.equal(tlCut.cameraCuts[0].t, 0.01, 'at its own time');
|
||||
tlCut.undo();
|
||||
assert.deepEqual(tlCut.cameraCuts, [{ t: 0, camera: 'c1' }], 'ONE undo puts the replaced cut back');
|
||||
tlCut.addCut(1.0, 'c2');
|
||||
assert.equal(tlCut.cameraCuts.length, 2, 'a cut a whole second later appends normally');
|
||||
const goneCut = tlCut.cameraCuts[1];
|
||||
tlCut.removeCut(goneCut);
|
||||
assert.equal(tlCut.cameraCuts.length, 1, 'removeCut removes');
|
||||
tlCut.undo();
|
||||
assert.deepEqual(tlCut.cameraCuts, [{ t: 0, camera: 'c1' }, { t: 1, camera: 'c2' }],
|
||||
'and undo restores it in place (a raw splice used to revert an unrelated edit instead)');
|
||||
tlCut.moveCut(tlCut.cameraCuts[1], 4);
|
||||
assert.equal(tlCut.cameraCuts[1].t, 4, 'moveCut moves');
|
||||
tlCut.undo();
|
||||
assert.equal(tlCut.cameraCuts[1].t, 1, 'and one undo puts it back');
|
||||
tlCut.seek(2);
|
||||
assert.equal(cutStage._active, 'c2', 'the cut is live at t=2');
|
||||
cutStage.setActiveCamera('c1'); // something changed it behind our back (dock ◉)
|
||||
tlCut.seek(2.5);
|
||||
assert.equal(cutStage._active, 'c2', 'a seek re-asserts the cut-derived camera — the cached value cannot desync');
|
||||
|
||||
// ---- the names column as an outliner: rowFlags ------------------------------
|
||||
assert.deepEqual(rowFlags(tlCut.scene.entities[0]), { keyed: false, selectable: true },
|
||||
'a camera with no tracks is selectable but not keyed');
|
||||
assert.deepEqual(rowFlags({ id: 'e', tracks: { transform: [{ t: 0 }] } }), { keyed: true, selectable: true },
|
||||
'one transform key = keyed (the timeline re-applies it on every seek)');
|
||||
assert.deepEqual(rowFlags({ id: 'e', tracks: { params: [{ t: 0, key: 'fov' }] } }), { keyed: false, selectable: true },
|
||||
'param keys do NOT drive the transform, so they are not a keyed pose');
|
||||
assert.deepEqual(rowFlags({ id: 'e', tracks: {} }), { keyed: false, selectable: true }, 'empty tracks are not keyed');
|
||||
assert.deepEqual(rowFlags(null), { keyed: false, selectable: false },
|
||||
'the Cameras and audio rows have no entity and never select');
|
||||
|
||||
// ---- REVIEW-FIX: one capture loop at a time (⏺ Render vs 🎬 Render film) ----
|
||||
// Both drive timeline.step() + stage.renderActiveCamera() and both bracket the
|
||||
// capture with stage.pause()/resume(); run together they interleave frames into
|
||||
// two render sessions and the first one to finish un-hides the gizmo for the
|
||||
// REST of the other one's frames. The pure gate…
|
||||
assert.equal(renderRefusal({}), null, 'idle: nothing refuses a render');
|
||||
assert.match(renderRefusal({ rendering: true }), /⏺ scene render/, 'a second ⏺ click is refused');
|
||||
assert.match(renderRefusal({ filmBusy: true }), /🎬 film render/, 'and ⏺ during a film render');
|
||||
assert.match(renderRefusal({ rendering: true, filmBusy: true }), /⏺ scene render/, 'the nearer one wins the message');
|
||||
// …and the WIRING, driven headlessly through the prototype with a fake `this`.
|
||||
// (On the old code both of these reached the point of no return.)
|
||||
let planned = false, gateToast = null;
|
||||
await TimelineUI.prototype.render.call(Object.assign(Object.create(TimelineUI.prototype), {
|
||||
_rendering: false, _filmBusy: true, // 🎬 film render in flight
|
||||
$rsize: { value: '1280x720' },
|
||||
_toast: (m) => { gateToast = m; },
|
||||
_errText: (e) => String(e),
|
||||
renderPlan() { planned = true; throw new Error('must not get here'); },
|
||||
}));
|
||||
assert.equal(planned, false, '⏺ Render never even plans while a film render runs');
|
||||
assert.match(gateToast, /🎬 film render is running/, 'and says why');
|
||||
let filmProceeded = false, gateStatus = null;
|
||||
await TimelineUI.prototype._filmRender.call({
|
||||
_seq: { shots: [{ scene: 's', in: 0, out: 5 }] }, _filmBusy: false,
|
||||
_rendering: true, // ⏺ scene render in flight
|
||||
tl: { hasContent: () => false },
|
||||
_filmStatus: (m) => { gateStatus = m; return { appendChild() {}, append() {} }; },
|
||||
async _filmSave() { filmProceeded = true; return null; },
|
||||
});
|
||||
assert.equal(filmProceeded, false, '🎬 Render film never saves or renders while ⏺ runs');
|
||||
assert.match(gateStatus, /⏺ scene render is already running/, 'and says why');
|
||||
// the scene bar drives the SAME timeline (Save / Load / New… / ▶), so a film
|
||||
// render has to take it out too — _filmBusySet only disabled the film panel.
|
||||
const barEls = [{ disabled: false }, { disabled: false }, { disabled: false }];
|
||||
const busyUi = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
$bar: { querySelectorAll: () => barEls },
|
||||
$film: { querySelectorAll: () => [] },
|
||||
$render: {}, _filmEnable() {},
|
||||
});
|
||||
busyUi._filmBusySet(true);
|
||||
assert.ok(barEls.every((el) => el.disabled), 'a film render disables the whole scene bar');
|
||||
assert.equal(busyUi._busy(), true, 'and the canvas gate reads busy');
|
||||
busyUi._filmBusySet(false);
|
||||
assert.ok(barEls.every((el) => !el.disabled), 'and hands it back when the film is done');
|
||||
assert.equal(busyUi._busy(), false, 'gate clear');
|
||||
|
||||
// ---- REVIEW-FIX: audio is refused BEFORE the frames, not after --------------
|
||||
// server.py's resolve_audio runs at POST /render/{id}/end — every PNG is already
|
||||
// uploaded by then. Everything it can refuse without touching the filesystem is
|
||||
// refused client-side, exactly like the frame cap.
|
||||
assert.deepEqual(audioProblems([]), [], 'no audio is not a problem');
|
||||
assert.deepEqual(audioProblems([{ path: 'audio/vo1.wav', start: 0.5, gain: 0.8 }]), [], 'a good clip passes');
|
||||
assert.deepEqual(audioProblems([{ path: 'a.wav', in: 0, out: 4 }]), [], 'so does a trimmed one');
|
||||
assert.match(audioProblems([{ start: 0 }])[0], /needs a string path/, 'a pathless clip is caught');
|
||||
assert.match(audioProblems([{ path: 'a.wav', start: -2 }])[0], /start\/in must be >= 0/, 'negative start');
|
||||
assert.match(audioProblems([{ path: 'a.wav', in: 4, out: 1 }])[0], /must be less than out/, 'inverted trim');
|
||||
assert.match(audioProblems([{ path: 'a.wav', gain: 'loud' }])[0], /must be numbers/, 'a non-numeric gain');
|
||||
assert.equal(audioProblems([{ path: 'a.wav', in: 4, out: 1 }, { path: 'b.wav', start: -1 }]).length, 2,
|
||||
'every bad clip is named, not just the first');
|
||||
assert.match(audioProblems([{ path: 'a.wav', start: -1 }])[0], /audio\[0\] \(a\.wav\)/, 'and named by index + path');
|
||||
// wiring: ⏺ Render refuses before it touches the clock (i.e. before frame 1)
|
||||
let auToast = null;
|
||||
await TimelineUI.prototype.render.call(Object.assign(Object.create(TimelineUI.prototype), {
|
||||
_rendering: false, _filmBusy: false, $rsize: { value: '1280x720' },
|
||||
_toast: (m) => { auToast = m; }, _errText: (e) => String(e),
|
||||
renderPlan: () => ({ width: 1280, height: 720, fps: 30, name: 'x', total: 60,
|
||||
audio: [{ path: 'audio/vo1.wav', in: 4, out: 1 }] }),
|
||||
_audioMissing: async () => [], // existence needs the server; not this assertion
|
||||
tl: { pause() { throw new Error('the render must not start'); } },
|
||||
}));
|
||||
assert.match(auToast, /must be less than out/, 'a broken audio trim is refused up front, not after 600 PNGs');
|
||||
// a missing file is refused too — and the refusal hands the timeline back
|
||||
let mvToast = null;
|
||||
const mvBar = [{ disabled: false }];
|
||||
const mvUi = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
_rendering: false, _filmBusy: false, $rsize: { value: '1280x720' },
|
||||
$bar: { querySelectorAll: () => mvBar },
|
||||
$renderBtn: { textContent: '⏺ Render' }, $rstat: { textContent: '' }, draw() {},
|
||||
_toast: (m) => { mvToast = m; }, _errText: (e) => String(e),
|
||||
renderPlan: () => ({ width: 1280, height: 720, fps: 30, name: 'x', total: 60, audio: [{ path: 'audio/gone.wav' }] }),
|
||||
_audioMissing: async () => ['audio/gone.wav'],
|
||||
tl: { time: 3, seek() {}, pause() { throw new Error('the render must not start'); } },
|
||||
});
|
||||
await mvUi.render();
|
||||
assert.match(mvToast, /audio not found under assets — audio\/gone\.wav/, 'a moved/deleted audio file is named before frame 1');
|
||||
assert.equal(mvUi._rendering, false, 'and the refusal releases the gate');
|
||||
assert.equal(mvBar[0].disabled, false, 'and re-arms the scene bar');
|
||||
|
||||
// ---- REVIEW-FIX: cutting to a camera from the Cameras row -------------------
|
||||
// The old gesture cut to stage.activeCamera(), but _evalCameraCuts puts the stage
|
||||
// back on the EDIT's camera at every seek — so after any scrub it could only ever
|
||||
// write a no-op cut on top of the camera already live, and with 2+ cameras and no
|
||||
// beat grid there was NO way to cut to camera 2 from the timeline at all.
|
||||
const cutsFx = [{ t: 0, camera: 'cam1' }, { t: 8, camera: 'cam2' }];
|
||||
assert.equal(cutAction(cutsFx, 4, 'cam2', 30).action, 'add', 'a real angle change at 4s is a new cut');
|
||||
assert.equal(cutAction(cutsFx, 4, 'cam1', 30).action, 'noop', 'cutting to the camera already live does nothing');
|
||||
assert.equal(cutAction([], 4, 'cam1', 30).action, 'add', 'the first cut in a cutless scene always lands');
|
||||
assert.equal(cutAction(cutsFx, 8.01, 'cam3', 30).action, 'replace', 'inside half a frame of a cut = replace');
|
||||
assert.equal(cutAction(cutsFx, 8.01, 'cam3', 30).at, cutsFx[1], 'and it names the cut being replaced');
|
||||
const redundant = [{ t: 0, camera: 'cam1' }, { t: 5, camera: 'cam2' }, { t: 9, camera: 'cam1' }];
|
||||
assert.equal(cutAction(redundant, 9, 'cam2', 30).action, 'remove',
|
||||
'asking for the camera live BEFORE a cut removes that cut instead of stacking a dead one');
|
||||
assert.equal(cutAction(cutsFx, 9, 'cam2', 30).action, 'noop', 'the same ask with no cut there is simply refused');
|
||||
assert.equal(cutAction([{ t: 6, camera: 'cam2' }], 3, 'cam1', 30).action, 'add',
|
||||
'before the first cut the director cam is live, so any camera is a change');
|
||||
// the whole point, end to end: two cameras, a scrub, and cam2 is still reachable
|
||||
const pkStage = new StageStub();
|
||||
const tlPk = new Timeline(pkStage);
|
||||
tlPk.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
await pkStage.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: { fov: 45 } });
|
||||
pkStage.setActiveCamera('cam1');
|
||||
await pkStage.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', source: { type: 'none' }, params: { fov: 45 } });
|
||||
pkStage.setActiveCamera('cam2'); // dock lights the PiP on the new camera
|
||||
tlPk.seek(1.0); // …and the edit takes it straight back
|
||||
assert.equal(pkStage.activeCamera(), 'cam1', 'after a scrub the stage is on the EDIT’s camera (the old gesture read this)');
|
||||
const pkToasts = [];
|
||||
TimelineUI.prototype._cutTo.call({
|
||||
stage: pkStage, tl: tlPk, _te: (id) => tlPk.scene.entities.find((e) => e.id === id),
|
||||
_toast: (m) => pkToasts.push(m), draw() {},
|
||||
}, 4, 'cam2');
|
||||
assert.deepEqual(tlPk.cameraCuts, [{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }],
|
||||
'picking cam2 from the row cuts to cam2 — not to whatever the stage happens to show');
|
||||
tlPk.seek(4.5);
|
||||
assert.equal(pkStage.activeCamera(), 'cam2', 'and the cut is live');
|
||||
TimelineUI.prototype._cutTo.call({
|
||||
stage: pkStage, tl: tlPk, _te: (id) => tlPk.scene.entities.find((e) => e.id === id),
|
||||
_toast: (m) => pkToasts.push(m), draw() {},
|
||||
}, 6, 'cam2');
|
||||
assert.equal(tlPk.cameraCuts.length, 2, 'asking for the camera already live adds NO dead cut');
|
||||
assert.match(pkToasts[pkToasts.length - 1], /already live/, 'it says so instead of pretending');
|
||||
// _cutPick's no-menu branches (one camera / none) need no DOM
|
||||
const pk1 = new StageStub();
|
||||
const tlPk1 = new Timeline(pk1);
|
||||
tlPk1.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
await pk1.addEntity({ id: 'only', kind: 'camera', label: 'only', source: { type: 'none' }, params: {} });
|
||||
const pk1Ui = Object.assign(Object.create(TimelineUI.prototype),
|
||||
{ stage: pk1, tl: tlPk1, _te: () => null, _toast: (m) => pkToasts.push(m), draw() {} });
|
||||
tlPk1.scene.cameraCuts.length = 0; // _mirror's automatic first cut, out of the way
|
||||
TimelineUI.prototype._cutPick.call(pk1Ui, 3);
|
||||
assert.deepEqual(tlPk1.cameraCuts, [{ t: 3, camera: 'only' }], 'one camera = no menu, just cut');
|
||||
const pk0 = new StageStub();
|
||||
const tlPk0 = new Timeline(pk0);
|
||||
tlPk0.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
let pk0Toast = null;
|
||||
TimelineUI.prototype._cutPick.call(Object.assign(Object.create(TimelineUI.prototype),
|
||||
{ stage: pk0, tl: tlPk0, _toast: (m) => { pk0Toast = m; } }), 3);
|
||||
assert.equal(tlPk0.cameraCuts.length, 0, 'no cameras = no cut invented');
|
||||
assert.match(pk0Toast, /no cameras/, 'and it says so');
|
||||
|
||||
// ---- REVIEW-FIX: the cut lane's camera label must be visible ----------------
|
||||
// It was drawn in '#161a20' — the odd lane's own background (contrast 1.00:1),
|
||||
// #13171d on the even one. The feature was advertised in the log and could not
|
||||
// be seen on screen. WCAG relative luminance, the same maths a browser devtool
|
||||
// contrast checker uses.
|
||||
const _lum = (hex) => {
|
||||
const v = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
||||
};
|
||||
const contrast = (a, b) => { const [x, y] = [_lum(a), _lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
|
||||
assert.equal(contrast('#161a20', '#161a20'), 1, 'sanity: the old label colour on the old lane fill was 1.00:1');
|
||||
for (const bg of LANE_BG) {
|
||||
assert.ok(contrast(CUT_COLOR, bg) >= 3,
|
||||
`the cut label must read against lane fill ${bg} (got ${contrast(CUT_COLOR, bg).toFixed(2)}:1)`);
|
||||
}
|
||||
|
||||
// ---- REVIEW-FIX: a dragged cut settles, and a drag is ONE undo entry --------
|
||||
const dcStage = new StageStub();
|
||||
const tlDC = new Timeline(dcStage);
|
||||
tlDC.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
const cutA = tlDC.addCut(2, 'cam1');
|
||||
const cutB = tlDC.addCut(6, 'cam2');
|
||||
const dcMark = tlDC.undoStack.length;
|
||||
for (let i = 0; i < 50; i++) tlDC.moveCut(cutB, 6 - (i * (4 / 49))); // one mousemove each
|
||||
assert.equal(tlDC.undoStack.length - dcMark, 50, 'a drag still records every step while it is happening');
|
||||
assert.equal(tlDC.settleCut(cutB), 1, 'dropping a cut on another absorbs it (addCut’s half-frame rule)');
|
||||
assert.equal(tlDC.cameraCuts.length, 1, 'so the lane never holds two cuts one frame apart');
|
||||
assert.equal(tlDC.cameraCuts[0], cutB, 'the one you dragged is the one that survives');
|
||||
tlDC.collapseUndo(dcMark);
|
||||
assert.equal(tlDC.undoStack.length, dcMark + 1, 'and mouseup collapses the whole drag into ONE undo entry');
|
||||
tlDC.undo();
|
||||
assert.equal(cutB.t, 6, 'one Ctrl+Z reverts the DRAG, not one mouse-frame of it');
|
||||
assert.deepEqual(tlDC.cameraCuts, [cutA, cutB], 'and the absorbed cut comes back with it');
|
||||
assert.equal(tlDC.collapseUndo(tlDC.undoStack.length), 0, 'collapsing nothing costs nothing');
|
||||
const oneMark = tlDC.undoStack.length;
|
||||
tlDC.addCut(9, 'cam2');
|
||||
tlDC.collapseUndo(oneMark);
|
||||
assert.equal(tlDC.undoStack.length, oneMark + 1, 'a single edit is not wrapped twice');
|
||||
tlDC.undo();
|
||||
assert.equal(tlDC.cameraCuts.length, 2, 'and still undoes exactly once');
|
||||
// …and the mouseup handler is what wires both (this is the drag the user does)
|
||||
const upTl = new Timeline(new StageStub());
|
||||
upTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
const upKeep = upTl.addCut(2, 'cam1');
|
||||
const upDrag = upTl.addCut(6, 'cam2');
|
||||
const upBase = upTl.undoStack.length;
|
||||
const upUi = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
tl: upTl, _undoMark: upBase, _drag: { kind: 'cut', h: { cut: upDrag } },
|
||||
_toast() {}, draw() {},
|
||||
});
|
||||
for (let i = 0; i < 40; i++) upTl.moveCut(upDrag, 6 - (i * (4 / 39)));
|
||||
TimelineUI.prototype._up.call(upUi);
|
||||
assert.equal(upTl.cameraCuts.length, 1, 'mouseup settles the dropped cut onto the one underneath');
|
||||
assert.equal(upTl.undoStack.length, upBase + 1, 'and the whole drag is ONE undo entry (was ~40)');
|
||||
upTl.undo();
|
||||
assert.equal(upDrag.t, 6, 'one Ctrl+Z and the cut is back where the drag began');
|
||||
assert.deepEqual(upTl.cameraCuts, [upKeep, upDrag], 'with the cut it swallowed restored too');
|
||||
|
||||
// ---- REVIEW-FIX: a save reports the name the SERVER used --------------------
|
||||
// server.py slugifies: `My Scene` is written as my-scene.json and GET /scenes
|
||||
// lists `my-scene`. The stem comes back in the response body and used to be
|
||||
// thrown away, so the toast named a file that does not exist.
|
||||
const svTl = new Timeline(new StageStub());
|
||||
svTl.load({ version: 1, name: 'x', fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [] });
|
||||
const svField = { value: 'My Scene' };
|
||||
let svToast = null;
|
||||
const svPosts = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
// server.py `scene_save` stores the BODY VERBATIM and only slugifies the URL, so
|
||||
// this fake keeps both and we can look at what actually landed on disk.
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
svPosts.push({ url, body: JSON.parse(opts.body) });
|
||||
return { ok: true, status: 200, async json() { return { ok: true, name: 'my-scene' }; } };
|
||||
};
|
||||
await TimelineUI.prototype.save.call(Object.assign(Object.create(TimelineUI.prototype), {
|
||||
$name: svField, tl: svTl, _toast: (m) => { svToast = m; }, _errText: (e) => String(e),
|
||||
}));
|
||||
globalThis.fetch = realFetch;
|
||||
assert.equal(svPosts[0].url, 'scenes/My%20Scene', 'the raw name is still what gets POSTed');
|
||||
assert.match(svToast, /saved "my-scene"/, 'but the toast names the file the server actually wrote');
|
||||
assert.equal(svField.value, 'my-scene', 'and the name field is corrected to match the Load picker');
|
||||
assert.equal(svTl.scene.name, 'my-scene', 'so the next save writes the same file');
|
||||
// REVIEW-FIX: the toast was right but the FILE still said "My Scene" — the server
|
||||
// writes the body as posted — so the next Load put the un-slugged name straight
|
||||
// back in the bar while the picker went on listing `my-scene`. One corrective
|
||||
// re-POST, keyed off the stem the server itself reported.
|
||||
assert.equal(svPosts.length, 2, 'a slugged name is written back with one corrective POST');
|
||||
assert.equal(svPosts[1].url, 'scenes/my-scene', 'to the stem the server reported');
|
||||
assert.equal(svPosts[1].body.name, 'my-scene', 'and the stored scene now agrees with its own filename');
|
||||
// …and a name that needed no slugging costs exactly one request
|
||||
const svTl2 = new Timeline(new StageStub());
|
||||
svTl2.load({ version: 1, name: 'x', fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [] });
|
||||
svPosts.length = 0;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
svPosts.push({ url, body: JSON.parse(opts.body) });
|
||||
return { ok: true, status: 200, async json() { return { ok: true, name: 'already-slug' }; } };
|
||||
};
|
||||
await TimelineUI.prototype.save.call(Object.assign(Object.create(TimelineUI.prototype), {
|
||||
$name: { value: 'already-slug' }, tl: svTl2, _toast() {}, _errText: (e) => String(e),
|
||||
}));
|
||||
globalThis.fetch = realFetch;
|
||||
assert.equal(svPosts.length, 1, 'a name the server did not change is saved with a single POST');
|
||||
|
||||
// ---- REVIEW-FIX: the audio existence check must use a method the server ANSWERS
|
||||
// It sent HEAD, which made the whole pre-flight inert: FastAPI's APIRoute
|
||||
// registers only the methods you decorate (starlette's own Route adds HEAD to a
|
||||
// GET route — APIRoute does not), so `@app.get("/assets/file")` answers every
|
||||
// HEAD with 405 and `status === 404` could never be true. Measured against a real
|
||||
// uvicorn on this repo: HEAD → 405 `allow: GET`; GET → 404; GET with
|
||||
// `Range: bytes=0-0` → 206 + content-length 1. This fake answers the same way, so
|
||||
// a check that talks to the server wrongly cannot pass here either.
|
||||
const amSeen = [];
|
||||
const fastapiish = async (url, opts = {}) => {
|
||||
const method = (opts.method || 'GET').toUpperCase();
|
||||
const p = decodeURIComponent(String(url).split('path=')[1] || '');
|
||||
const range = (opts.headers || {}).Range || (opts.headers || {}).range || null;
|
||||
amSeen.push({ method, path: p, range });
|
||||
if (method !== 'GET') return { status: 405, body: null }; // FastAPI: allow: GET
|
||||
if (p !== 'audio/here.wav') return { status: 404, body: null };
|
||||
let cancelled = false;
|
||||
return { status: range ? 206 : 200, body: { cancel: async () => { cancelled = true; return cancelled; } } };
|
||||
};
|
||||
const amFetch = globalThis.fetch;
|
||||
globalThis.fetch = fastapiish;
|
||||
const amMissing = await TimelineUI.prototype._audioMissing.call({},
|
||||
[{ path: 'audio/here.wav' }, { path: 'audio/gone.wav' }, { path: 'audio/gone.wav' }, { path: '' }, null]);
|
||||
globalThis.fetch = amFetch;
|
||||
assert.deepEqual(amMissing, ['audio/gone.wav'], 'a moved/deleted audio file is actually DETECTED (a HEAD-based check never could)');
|
||||
assert.ok(amSeen.length > 0, 'the check talks to the server at all');
|
||||
assert.ok(amSeen.every((r) => r.method === 'GET'),
|
||||
`every request uses a method the route answers — HEAD is 405 on every FastAPI GET route (saw ${amSeen.map((r) => r.method).join(',')})`);
|
||||
assert.ok(amSeen.every((r) => r.range === 'bytes=0-0'), 'and asks for ONE byte, not the whole stem');
|
||||
assert.equal(amSeen.length, 2, 'one request per DISTINCT path, blanks skipped');
|
||||
// fails OPEN — a wobbling network must never block a render the server would take
|
||||
globalThis.fetch = async () => { throw new Error('offline'); };
|
||||
assert.deepEqual(await TimelineUI.prototype._audioMissing.call({}, [{ path: 'audio/x.wav' }]), [],
|
||||
'a network failure is not a missing file — the server still gets the last word');
|
||||
globalThis.fetch = amFetch;
|
||||
|
||||
// ---- REVIEW-FIX: ⏺ Render and 🎬 Render film really are mutually exclusive ----
|
||||
// The film gate used to be claimed only AFTER `await this._filmSave()`, so during
|
||||
// that round-trip both flags were false, the scene bar was live, and a ⏺ Render
|
||||
// click walked straight through renderRefusal. Two capture loops then stepped the
|
||||
// same clock and the first `finally` re-armed the bar under the other one.
|
||||
{
|
||||
const log = [];
|
||||
let releaseSave;
|
||||
const savePending = new Promise((r) => { releaseSave = r; });
|
||||
const bar = [{ disabled: false }];
|
||||
const ui = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
_rendering: false, _filmBusy: false,
|
||||
_seq: { shots: [{ scene: 's1', in: 0, out: 5 }] }, _seqName: 'seq',
|
||||
$size: { value: '1280x720' }, $rsize: { value: '1280x720' },
|
||||
$bar: { querySelectorAll: () => bar }, $film: { querySelectorAll: () => [] },
|
||||
$render: {}, $renderBtn: { textContent: '⏺ Render' }, $rstat: { textContent: '', appendChild() {} },
|
||||
_filmEnable() {},
|
||||
_filmStatus(m) { log.push('film-status: ' + m); return { appendChild() {}, append() {} }; },
|
||||
_toast(m) { log.push('toast: ' + m); }, _errText: (e) => String(e),
|
||||
_syncRows() {}, _refreshBar() {}, draw() {}, _audioStop() {},
|
||||
async _filmSave() { await savePending; return { name: 'seq' }; },
|
||||
async _filmMod() { return { async renderSequence() { log.push('FILM CAPTURE'); return 'films/x.mp4'; } }; },
|
||||
renderPlan: () => ({ width: 1280, height: 720, fps: 30, name: 'x', total: 60, audio: [] }),
|
||||
async _audioMissing() { return []; },
|
||||
tl: { time: 0, locked: false, hasContent: () => false, pause() { log.push('SCENE CAPTURE'); }, seek() {} },
|
||||
});
|
||||
const oldConfirm = globalThis.confirm;
|
||||
globalThis.confirm = () => true;
|
||||
const filmP = ui._filmRender();
|
||||
await new Promise((r) => setTimeout(r, 0)); // film is parked on its save POST
|
||||
assert.equal(ui._filmBusy, true, 'the film claims the gate BEFORE its first await, not after');
|
||||
assert.equal(bar[0].disabled, true, 'and the scene bar is dead for the whole thing, not just the capture');
|
||||
assert.equal(ui.tl.locked, true, 'and the model knows (dock/DIRECT events are outside the bar)');
|
||||
await ui.render(); // the ⏺ click that used to get through
|
||||
assert.ok(!log.includes('SCENE CAPTURE'), '⏺ Render cannot start while a film render is in its pre-flight');
|
||||
assert.match(log.find((l) => l.startsWith('toast:')) || '', /film render is running/, 'and it says why');
|
||||
releaseSave();
|
||||
await filmP;
|
||||
globalThis.confirm = oldConfirm;
|
||||
assert.ok(log.includes('FILM CAPTURE'), 'the film render itself still runs');
|
||||
assert.equal(ui._filmBusy, false, 'and hands the gate back when it is done');
|
||||
assert.equal(bar[0].disabled, false, 'bar re-armed');
|
||||
assert.equal(ui.tl.locked, false, 'model unlocked');
|
||||
}
|
||||
// the same gate the other way round, and released by a FINALLY: a film save that
|
||||
// fails must not leave the app permanently "rendering" with a dead scene bar.
|
||||
{
|
||||
const bar = [{ disabled: false }];
|
||||
const ui = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
_rendering: false, _filmBusy: false, _seq: { shots: [{ scene: 's1' }] }, _seqName: 'seq',
|
||||
$size: { value: '1280x720' }, $bar: { querySelectorAll: () => bar }, $film: { querySelectorAll: () => [] },
|
||||
$render: {}, _filmEnable() {}, _filmStatus() { return { appendChild() {}, append() {} }; },
|
||||
_toast() {}, _errText: (e) => String(e), _syncRows() {}, _refreshBar() {}, draw() {},
|
||||
async _filmSave() { return null; }, // the save 422'd / the server is down
|
||||
async _filmMod() { throw new Error('must not get this far'); },
|
||||
tl: { locked: false, hasContent: () => false },
|
||||
});
|
||||
await ui._filmRender();
|
||||
assert.equal(ui._filmBusy, false, 'a failed film save releases the gate (finally, not a hand-written reset)');
|
||||
assert.equal(bar[0].disabled, false, 'and the scene bar comes back');
|
||||
assert.equal(ui.tl.locked, false, 'and the model is unlocked again');
|
||||
ui._rendering = true; // now a ⏺ render owns it
|
||||
ui._seq = { shots: [{ scene: 's1' }] };
|
||||
let why = null;
|
||||
ui._filmStatus = (m) => { why = m; return { appendChild() {}, append() {} }; };
|
||||
await ui._filmRender();
|
||||
assert.match(why, /scene render is already running/, '🎬 Render film refuses while ⏺ Render owns the clock');
|
||||
assert.equal(ui._filmBusy, false, 'and does not claim the gate on the way out');
|
||||
}
|
||||
|
||||
// ---- REVIEW-FIX: a scene edit dispatched from OUTSIDE the scene bar ----------
|
||||
// _barBusySet can only disable controls inside $bar; Lane A's DIRECT panel and the
|
||||
// dock dispatch window events straight at timeline.js, so a shot preset clicked
|
||||
// mid-capture keyed the playhead the frame stepper was walking.
|
||||
{
|
||||
const oldWindow = globalThis.window;
|
||||
const said = [];
|
||||
globalThis.window = {
|
||||
innerWidth: 1200, _l: {},
|
||||
addEventListener(t, f) { (this._l[t] = this._l[t] || []).push(f); },
|
||||
removeEventListener(t, f) { this._l[t] = (this._l[t] || []).filter((x) => x !== f); },
|
||||
dispatchEvent(e) { for (const f of (this._l[e.type] || []).slice()) f(e); return true; },
|
||||
sgToast: (m) => said.push(m),
|
||||
};
|
||||
const lkStage = new StageStub();
|
||||
const lkTl = new Timeline(lkStage); // constructed WITH a window → real listeners
|
||||
lkTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
await lkStage.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: { fov: 45 } });
|
||||
await lkStage.addEntity({ id: 'sub', kind: 'character', label: 'sub', source: { type: 'none' }, params: {} });
|
||||
lkTl.seek(2);
|
||||
const cutsBefore = JSON.stringify(lkTl.cameraCuts);
|
||||
const keysBefore = JSON.stringify(lkTl.scene.entities.map((e) => e.tracks));
|
||||
said.length = 0; // drop _mirror's "cut at 0s → cam1"
|
||||
lkTl.locked = true; // …a capture loop is running
|
||||
globalThis.window.dispatchEvent({ type: 'scenegod:direct',
|
||||
detail: { op: 'shot', camId: 'cam1', pos: [0, 1, 5], rot: [0, 0, 0], fov: 35, entityIds: ['cam1', 'sub'] } });
|
||||
globalThis.window.dispatchEvent({ type: 'scenegod:capturekey', detail: { id: 'sub' } });
|
||||
assert.equal(JSON.stringify(lkTl.cameraCuts), cutsBefore, 'a DIRECT preset cannot cut under a running render');
|
||||
assert.equal(JSON.stringify(lkTl.scene.entities.map((e) => e.tracks)), keysBefore, 'and cannot key under one either');
|
||||
assert.equal(said.length, 2, 'both refusals are spoken, not swallowed');
|
||||
assert.match(said[0], /a render is capturing this timeline/, 'and they say why');
|
||||
lkTl.locked = false; // render over → the same click works
|
||||
globalThis.window.dispatchEvent({ type: 'scenegod:capturekey', detail: { id: 'sub' } });
|
||||
assert.equal(lkTl.scene.entities.find((e) => e.id === 'sub').tracks.transform.length, 1,
|
||||
'and the gate is only a gate — the op still works once the render is done');
|
||||
globalThis.window = oldWindow;
|
||||
}
|
||||
|
||||
// ---- REVIEW-FIX: cutAction must not MANUFACTURE the dead cut it prevents -----
|
||||
// It only inspected the cut BEFORE t. cuts [{0,cam1},{4,cam2}], cut at 0 to cam2:
|
||||
// `before` is null (nothing precedes 0) → 'replace' → [{0,cam2},{4,cam2}] and the
|
||||
// 4s cut renders as nothing while reading as an edit — the exact artefact the M10
|
||||
// amendment asked to be dropped.
|
||||
{
|
||||
const cs = [{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }];
|
||||
const r = cutAction(cs, 0, 'cam2', 30);
|
||||
assert.equal(r.action, 'replace', 'replacing the 0s cut with cam2 is still the right action');
|
||||
assert.equal(r.dead, cs[1], 'but the 4s cut is now dead and is named');
|
||||
const add = cutAction([{ t: 0, camera: 'cam1' }, { t: 6, camera: 'cam3' }], 3, 'cam3', 30);
|
||||
assert.equal(add.action, 'add', 'a new cut at 3s to cam3 is a real change');
|
||||
assert.equal(add.dead && add.dead.t, 6, 'and it makes the 6s cut to the same camera redundant');
|
||||
const rem = cutAction([{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }, { t: 8, camera: 'cam1' }], 4, 'cam1', 30);
|
||||
assert.equal(rem.action, 'remove', 'removing a cut back to the camera already live');
|
||||
assert.equal(rem.dead && rem.dead.t, 8, 'also strands the next cut to that camera');
|
||||
assert.equal(cutAction([{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam1' }], 2, 'cam1', 30).dead, null,
|
||||
'a cut that was ALREADY dead before this edit is somebody else’s mess — left alone');
|
||||
assert.equal(cutAction([{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }], 2, 'cam3', 30).dead, null,
|
||||
'and an unrelated following cut is never touched');
|
||||
// end to end, through the real gesture, as ONE undo entry
|
||||
const dcStage2 = new StageStub();
|
||||
const dcTl = new Timeline(dcStage2);
|
||||
dcTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
await dcStage2.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: {} });
|
||||
await dcStage2.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', source: { type: 'none' }, params: {} });
|
||||
dcTl.scene.cameraCuts.length = 0;
|
||||
dcTl.addCut(0, 'cam1'); dcTl.addCut(4, 'cam2');
|
||||
const dcBefore = JSON.parse(JSON.stringify(dcTl.cameraCuts));
|
||||
const dcMark2 = dcTl.undoStack.length;
|
||||
const dcToasts = [];
|
||||
TimelineUI.prototype._cutTo.call({
|
||||
stage: dcStage2, tl: dcTl, _te: (id) => dcTl.scene.entities.find((e) => e.id === id),
|
||||
_toast: (m) => dcToasts.push(m), draw() {},
|
||||
}, 0, 'cam2');
|
||||
assert.deepEqual(dcTl.cameraCuts, [{ t: 0, camera: 'cam2' }],
|
||||
'cutting to cam2 at 0 does NOT leave a 4s cut to the camera already live');
|
||||
assert.match(dcToasts[dcToasts.length - 1], /dropped the now-dead cut at 4\.00s/, 'and it says what it dropped');
|
||||
assert.equal(dcTl.undoStack.length, dcMark2 + 1, 'the whole gesture is ONE undo entry');
|
||||
dcTl.undo();
|
||||
assert.deepEqual(dcTl.cameraCuts, dcBefore, 'and one Ctrl+Z puts both cuts back');
|
||||
}
|
||||
|
||||
// ---- REVIEW-FIX: the mousedown half of the one-entry-per-drag fix ------------
|
||||
// The `_up` test used to hand-set `_undoMark` on the fake `this`, so deleting the
|
||||
// `_undoMark = this.tl.undoStack.length` line in `_down` left the suite green
|
||||
// while every drag went back to ~50 undo entries. Do the whole gesture instead.
|
||||
{
|
||||
const dgTl = new Timeline(new StageStub());
|
||||
dgTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
const keep = dgTl.addCut(2, 'cam1');
|
||||
const dragged = dgTl.addCut(6, 'cam2');
|
||||
const base = dgTl.undoStack.length;
|
||||
const ui = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
tl: dgTl, _drag: null, _undoMark: null, _selKeys: [],
|
||||
_busy: () => false, _local: () => ({ x: 200, y: 120 }), _x2t: (x) => x / 20,
|
||||
_hitAt: () => ({ kind: 'cut', cut: dragged }), _toast() {}, draw() {},
|
||||
});
|
||||
TimelineUI.prototype._down.call(ui, {});
|
||||
assert.equal(ui._undoMark, base, 'mousedown marks the undo depth');
|
||||
assert.equal(ui._drag.kind, 'cut', 'and starts the drag');
|
||||
for (let i = 0; i < 40; i++) dgTl.moveCut(dragged, 6 - (i * (4 / 39)));
|
||||
TimelineUI.prototype._up.call(ui);
|
||||
assert.equal(dgTl.undoStack.length, base + 1, 'and the whole down→move→up drag is ONE undo entry');
|
||||
dgTl.undo();
|
||||
assert.equal(dragged.t, 6, 'one Ctrl+Z reverts the drag');
|
||||
assert.deepEqual(dgTl.cameraCuts, [keep, dragged], 'with the absorbed cut restored');
|
||||
// …and both handlers refuse outright while a render owns the clock
|
||||
const boom = () => { throw new Error('the canvas must not be read while a render is capturing'); };
|
||||
const bz = Object.assign(Object.create(TimelineUI.prototype),
|
||||
{ _busy: () => true, _local: boom, _undoMark: null, _drag: null });
|
||||
TimelineUI.prototype._down.call(bz, {});
|
||||
assert.equal(bz._undoMark, null, '_down refuses while busy');
|
||||
assert.equal(bz._drag, null, 'and starts no drag');
|
||||
TimelineUI.prototype._dbl.call(bz, {}); // throws if the gate is gone
|
||||
TimelineUI.prototype._ctx.call(Object.assign(Object.create(TimelineUI.prototype),
|
||||
{ _busy: () => true, _local: boom }), { preventDefault() {} });
|
||||
}
|
||||
|
||||
// ---- REVIEW-FIX: a keyed param keeps a rest value, and says when an edit dies -
|
||||
// A keyed param with no authored rest value was DELETED from `params` on every
|
||||
// save (a keyed camera saved with no fov at all), and an inspector edit to it —
|
||||
// dock.js `_param` calls syncFromStage directly — vanished with no feedback.
|
||||
{
|
||||
const kpStage = new StageStub();
|
||||
const kpTl = new Timeline(kpStage);
|
||||
await kpTl.applyScene({ // onto the STAGE too — syncFromStage reads captureState()
|
||||
version: 1, fps: 30, duration: 10, cameraCuts: [], audio: [],
|
||||
entities: [{ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' },
|
||||
params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 },
|
||||
tracks: { transform: [], params: [{ t: 0, key: 'fov', value: 80, ease: 'linear' },
|
||||
{ t: 4, key: 'fov', value: 20, ease: 'linear' }], clips: [] } }],
|
||||
});
|
||||
kpTl.seek(2); // fov is mid-lerp on the stage right now
|
||||
kpTl.syncFromStage();
|
||||
assert.equal(kpTl.toJSON().entities[0].params.fov, 80,
|
||||
'an unauthored keyed param rests at its FIRST key, not at this tick’s lerp and not gone');
|
||||
kpTl.seek(3.5); kpTl.syncFromStage();
|
||||
assert.equal(kpTl.toJSON().entities[0].params.fov, 80, 'and the value does not move with the playhead');
|
||||
// the silent-discard half: an inspector edit to a keyed param
|
||||
const kpSaid = [];
|
||||
const oldWin = globalThis.window;
|
||||
globalThis.window = { sgToast: (m) => kpSaid.push(m), addEventListener() {} };
|
||||
kpStage.setParam('cam1', 'fov', 12); // exactly what dock.js `_param` does…
|
||||
kpTl.syncFromStage(); // …followed by exactly this
|
||||
assert.equal(kpSaid.length, 1, 'an edit to a KEYED param is no longer discarded in silence');
|
||||
assert.match(kpSaid[0], /fov is keyed/, 'and the message names the param');
|
||||
assert.equal(kpTl.toJSON().entities[0].params.fov, 80, 'the authored rest value is still what gets saved');
|
||||
kpSaid.length = 0;
|
||||
kpTl.seek(1); kpTl.syncFromStage(); // the timeline’s OWN lerp is not an edit
|
||||
assert.equal(kpSaid.length, 0, 'and the timeline driving its own keyed param says nothing');
|
||||
globalThis.window = oldWin;
|
||||
}
|
||||
|
||||
// ---- REVIEW-FIX: the Cameras-row camera picker closes properly --------------
|
||||
// The menu was a local `const` invisible to `this`, so Escape closed nothing and
|
||||
// picking a camera left the document mousedown listener (and a detached node)
|
||||
// registered until the next click anywhere.
|
||||
{
|
||||
const oldDoc = globalThis.document, oldWin = globalThis.window;
|
||||
const mkEl = () => ({
|
||||
children: [], className: '', textContent: '', style: {}, offsetWidth: 120, parent: null,
|
||||
appendChild(c) { this.children.push(c); c.parent = this; return c; },
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter((x) => x !== this); this.gone = true; },
|
||||
contains(n) { return n === this || this.children.some((c) => c.contains && c.contains(n)); },
|
||||
});
|
||||
const docL = {};
|
||||
globalThis.document = {
|
||||
createElement: mkEl, body: mkEl(),
|
||||
addEventListener(t, f) { (docL[t] = docL[t] || []).push(f); },
|
||||
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
|
||||
};
|
||||
globalThis.window = { innerWidth: 1200, addEventListener() {} };
|
||||
const pkS = new StageStub();
|
||||
const pkT = new Timeline(pkS);
|
||||
pkT.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
|
||||
await pkS.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: {} });
|
||||
await pkS.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', source: { type: 'none' }, params: {} });
|
||||
pkT.scene.cameraCuts.length = 0;
|
||||
const ui = Object.assign(Object.create(TimelineUI.prototype), {
|
||||
stage: pkS, tl: pkT, _pickMenu: null, _pickAway: null, _busy: () => false,
|
||||
_te: (id) => pkT.scene.entities.find((e) => e.id === id),
|
||||
_toast() {}, draw() {}, _helpClose() {}, _cutMenuClose() {},
|
||||
});
|
||||
TimelineUI.prototype._cutPick.call(ui, 3, { clientX: 10, clientY: 10 });
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
assert.ok(ui._pickMenu, 'the picker is tracked on `this` (Escape can find it)');
|
||||
assert.equal((docL.mousedown || []).length, 1, 'one away-click listener while it is open');
|
||||
TimelineUI.prototype._key.call(ui, { key: 'Escape', target: null });
|
||||
assert.equal(ui._pickMenu, null, 'Escape closes the picker, like every other overlay in this file');
|
||||
assert.equal((docL.mousedown || []).length, 0, 'and takes its listener with it');
|
||||
// and picking a camera cleans up too (this is the leak that survived every click)
|
||||
TimelineUI.prototype._cutPick.call(ui, 3, { clientX: 10, clientY: 10 });
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
const items = ui._pickMenu.children.filter((c) => c.className === 'item');
|
||||
assert.equal(items.length, 2, 'both cameras are offered');
|
||||
items[1].onclick();
|
||||
assert.equal(ui._pickMenu, null, 'choosing a camera closes the menu');
|
||||
assert.equal((docL.mousedown || []).length, 0, 'and unregisters the document listener');
|
||||
assert.deepEqual(pkT.cameraCuts, [{ t: 3, camera: 'cam2' }], 'and the cut it was opened for lands');
|
||||
globalThis.document = oldDoc; globalThis.window = oldWin;
|
||||
}
|
||||
|
||||
// ---- round-3 orchestrator: a saved scene NEVER carries a dangling camera cut ----
|
||||
// Two real histories found by adversarial review, both of which used to produce a
|
||||
// scene the server 422s on. The point is that neither is reachable-by-luck: any
|
||||
// undo entry older than the delete can put a cut back for a camera that is gone,
|
||||
// so the invariant is enforced at serialization, not by policing history.
|
||||
{
|
||||
const st = new StageStub();
|
||||
const tl = new Timeline(st);
|
||||
const cam = (id) => ({ id, kind: 'camera', label: id, source: { type: 'none' },
|
||||
params: { fov: 40 }, transform: { pos: [0, 1, 4], rot: [0, 0, 0], scale: 1 },
|
||||
tracks: { transform: [], params: [], clips: [] } });
|
||||
|
||||
// (A) an older removeCut is undone after the camera is deleted
|
||||
tl.load({ version: 1, name: 'a', fps: 30, duration: 10, entities: [cam('e1')],
|
||||
cameraCuts: [{ t: 0, camera: 'e1' }, { t: 5, camera: 'e1' }], audio: [] });
|
||||
tl.removeCut(tl.scene.cameraCuts[1]); // ordinary edit, undoable
|
||||
tl.scene.entities = []; // camera deleted (Stage side has no undo)
|
||||
tl.scene.cameraCuts = []; // dock's dropCuts cleared what it could see
|
||||
tl.undo(); // ONE Ctrl+Z resurrects the t=5 cut
|
||||
assert.ok(tl.scene.cameraCuts.some((c) => c.camera === 'e1'),
|
||||
'precondition: undo really does put a cut back for the deleted camera');
|
||||
assert.deepEqual(tl.toJSON().cameraCuts, [],
|
||||
'A: a resurrected cut for a deleted camera never reaches the saved scene');
|
||||
|
||||
// (B) an addCut that REPLACED an earlier cut restores it on undo
|
||||
const tl2 = new Timeline(new StageStub());
|
||||
tl2.load({ version: 1, name: 'b', fps: 30, duration: 10, entities: [cam('e1'), cam('e2')],
|
||||
cameraCuts: [{ t: 0, camera: 'e1' }], audio: [] });
|
||||
tl2.addCut(0, 'e2'); // replaces e1's cut, remembers it as prev
|
||||
tl2.scene.entities = tl2.scene.entities.filter((e) => e.id !== 'e1'); // delete e1
|
||||
tl2.undo(); // restores prev → a cut naming the dead e1
|
||||
assert.ok(tl2.scene.cameraCuts.some((c) => c.camera === 'e1'), 'precondition: prev came back');
|
||||
assert.deepEqual(tl2.toJSON().cameraCuts.filter((c) => c.camera === 'e1'), [],
|
||||
'B: a restored prev cut for a deleted camera never reaches the saved scene');
|
||||
|
||||
// and playback must not chase the dead id either
|
||||
assert.equal(tl2.cutCameraAt(9), tl2.scene.cameraCuts.some((c) => c.camera === 'e2') ? 'e2' : null,
|
||||
'cutCameraAt skips cuts whose camera is gone');
|
||||
assert.deepEqual(tl2.danglingCuts().map((c) => c.camera), ['e1'], 'danglingCuts reports them');
|
||||
}
|
||||
|
||||
console.log('OK — timeline_test.mjs: all assertions passed');
|
||||
|
||||
@ -12,6 +12,15 @@ const ROW_H = 28;
|
||||
const RULER_H = 22;
|
||||
const KEY_R = 5;
|
||||
|
||||
// Canvas palette. EXPORTED because the cut lane's camera label was drawn in
|
||||
// `#161a20` — the odd lane's own background, contrast 1.00:1 — so the feature
|
||||
// ("the lane prints the camera's LABEL") was not observable on screen at all.
|
||||
// The colours now come from one place and timeline_test.mjs holds them to a
|
||||
// real contrast ratio against BOTH lane fills.
|
||||
export const LANE_BG = ['#13171d', '#161a20']; // [even row, odd row]
|
||||
export const CUT_COLOR = '#e0af68'; // cut bar + dot + its label
|
||||
export const LABEL_COLOR = '#e0e6ed'; // text on a clip/audio block
|
||||
|
||||
// ponytail: styles injected here, not written into Lane A's web/style.css —
|
||||
// keeps Lane B inside its own files (orchestrator confirmed this is permanent).
|
||||
const CSS = `
|
||||
@ -28,7 +37,14 @@ const CSS = `
|
||||
#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right}
|
||||
#timeline .tl-body{display:flex}
|
||||
#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c}
|
||||
#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
/* the names column is the OUTLINER: rows with an entity select it on the stage */
|
||||
#timeline .tl-names .row.pick{cursor:pointer}
|
||||
#timeline .tl-names .row.pick:hover{background:#1a2029}
|
||||
#timeline .tl-names .row.sel{border-left-color:#7aa2f7;color:#e0e6ed;background:#1a2029}
|
||||
#timeline .tl-names .row .kf{margin-left:auto;color:#7aa2f7;font-size:9px;padding-left:6px}
|
||||
#timeline .tl-bar .rstat{color:#57c99a;max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
#timeline .tl-bar .rstat a{color:#57c99a}
|
||||
#timeline .tl-names .row.head{margin-top:${RULER_H}px}
|
||||
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px}
|
||||
#timeline .tl-names .row.sub{color:#8b949e;padding-left:20px;font-size:11px}
|
||||
@ -106,6 +122,116 @@ export function filmRuntime(shots) {
|
||||
return Math.max(0, total);
|
||||
}
|
||||
|
||||
// ---- pure seams (exported so timeline_test.mjs can pin the behaviour) -------
|
||||
|
||||
// server.py's `render_frame` refuses a frame index outside [0, 20000] — a render
|
||||
// that would overrun it must be refused HERE, before 20000 PNGs are uploaded.
|
||||
const RENDER_MAX_FRAMES = 20000;
|
||||
|
||||
// What "⏺ Render" is about to do: the finalRender() opts for THIS scene at the
|
||||
// picked size. Throws rather than starting a render the server will reject.
|
||||
// Unparseable size → 1080p (the select can only produce good values; a hand-
|
||||
// edited DOM shouldn't render a 0×0 film).
|
||||
export function renderPlan(tl, sizeStr) {
|
||||
const m = /^\s*(\d+)\s*[x×]\s*(\d+)\s*$/.exec(String(sizeStr == null ? '' : sizeStr));
|
||||
const width = m ? +m[1] : 1920, height = m ? +m[2] : 1080;
|
||||
const fps = tl.fps;
|
||||
const total = Math.max(1, Math.round(tl.duration * fps));
|
||||
if (total > RENDER_MAX_FRAMES) throw new Error(`too long: ${total} frames (max ${RENDER_MAX_FRAMES})`);
|
||||
return { width, height, fps, name: tl.scene.name || 'untitled', audio: tl.scene.audio || [], total };
|
||||
}
|
||||
|
||||
// One capture loop at a time. ⏺ Render and 🎬 Render film BOTH drive
|
||||
// timeline.step() + stage.renderActiveCamera() and both bracket the capture with
|
||||
// stage.pause()/stage.resume() — run them together and they interleave frames
|
||||
// into two different render sessions, film.js swaps the scene under the ⏺
|
||||
// render's feet, and whichever finishes first calls resume(), un-hiding the
|
||||
// gizmo/grid/camera cones for the REST of the other one's frames (the SYNC7 bug,
|
||||
// again). Returns a refusal string, or null when it's safe to start.
|
||||
export function renderRefusal({ rendering, filmBusy } = {}) {
|
||||
if (rendering) return 'a ⏺ scene render is already running — wait for it to finish';
|
||||
if (filmBusy) return 'a 🎬 film render is running — wait for it to finish';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Everything server.py's `resolve_audio` refuses that can be seen WITHOUT the
|
||||
// filesystem. It runs at POST /render/{id}/end, i.e. after every PNG of the
|
||||
// render has been uploaded — minutes of work to be told `audio: in (4) must be
|
||||
// less than out (1)`. Same reasoning as the frame cap: refuse here. (Existence
|
||||
// needs the filesystem, so it is asked for separately — see _audioMissing.)
|
||||
export function audioProblems(audio) {
|
||||
const num = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||
const out = [];
|
||||
(audio || []).forEach((a, i) => {
|
||||
const at = `audio[${i}]`;
|
||||
if (!a || typeof a !== 'object' || typeof a.path !== 'string' || !a.path) { out.push(`${at}: needs a string path`); return; }
|
||||
const start = a.start == null ? 0 : a.start, gain = a.gain == null ? 1 : a.gain;
|
||||
const tIn = a.in == null ? 0 : a.in;
|
||||
if (!num(start) || !num(gain) || !num(tIn) || (a.out != null && !num(a.out))) {
|
||||
out.push(`${at} (${a.path}): start/gain/in/out must be numbers`); return;
|
||||
}
|
||||
if (start < 0 || tIn < 0) out.push(`${at} (${a.path}): start/in must be >= 0 (start=${start}, in=${tIn})`);
|
||||
else if (a.out != null && a.out <= tIn) out.push(`${at} (${a.path}): in (${tIn}) must be less than out (${a.out})`);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// What double-clicking the Cameras row should DO at t for the chosen camera,
|
||||
// given the cut list. `at` = the cut this one would replace (addCut's half-frame
|
||||
// window). Cutting to the camera that is ALREADY live renders as nothing but
|
||||
// reads as an edit in the file — the exact artefact the M10 auto-cut amendment
|
||||
// asked to be dropped — so it is refused, and a redundant cut already sitting
|
||||
// there is removed instead. Pure so timeline_test.mjs can pin every branch.
|
||||
//
|
||||
// REVIEW-FIX: it only ever looked BACKWARDS, so the edit could manufacture the
|
||||
// very artefact it exists to prevent. cuts [{0,cam1},{4,cam2}], cut at 0 to cam2:
|
||||
// `before` is null (nothing precedes 0) → 'replace' → [{0,cam2},{4,cam2}] and the
|
||||
// 4s cut now changes nothing. After ANY of the four branches the effective camera
|
||||
// from t onward is camId, so the cut immediately after t is dead iff it names
|
||||
// camId — but only report it when it was a real angle change BEFORE this edit
|
||||
// (`wasEff`), otherwise we would be deleting somebody else's pre-existing mess.
|
||||
export function cutAction(cuts, t, camId, fps = 30) {
|
||||
const eps = 0.5 / (fps || 30);
|
||||
const sorted = [...(cuts || [])].sort((a, b) => a.t - b.t);
|
||||
const at = sorted.find((c) => Math.abs(c.t - t) <= eps) || null;
|
||||
let before = null;
|
||||
for (const c of sorted) { if (c === at) continue; if (c.t <= t) before = c.camera; else break; }
|
||||
const next = sorted.find((c) => c !== at && c.t > t) || null;
|
||||
const wasEff = at ? at.camera : before; // what was live going into `next` before this edit
|
||||
const dead = (next && next.camera === camId && wasEff !== camId) ? next : null;
|
||||
if (before === camId) return { action: at ? 'remove' : 'noop', at, before, dead };
|
||||
return { action: at ? 'replace' : 'add', at, before, dead };
|
||||
}
|
||||
|
||||
// Entities whose bytes only exist in this browser session (a file dropped on the
|
||||
// page). They are written into the scene file but SKIPPED on load (stage.js
|
||||
// `applyState` skips source.type === 'upload' when the kind needs bytes), so a
|
||||
// scene built around a dropped character silently loses it — and everything
|
||||
// keyed on it. Labels, for the warning.
|
||||
export function uploadEntities(json) {
|
||||
return ((json && json.entities) || [])
|
||||
.filter((e) => e && e.source && e.source.type === 'upload')
|
||||
.map((e) => e.label || e.id);
|
||||
}
|
||||
|
||||
// The one place that decides what a save TELLS you. A 422 from validate_scene
|
||||
// used to look exactly like a successful save (silent localStorage fallback),
|
||||
// and since load() prefers the server copy, the next Load quietly restored the
|
||||
// last good version — with everything since it gone.
|
||||
export function saveMessage({ ok, status, errors, name, count } = {}) {
|
||||
if (ok) return `saved "${name}" · ${count} entit${count === 1 ? 'y' : 'ies'}`;
|
||||
if (status == null) return 'server unreachable — kept a local copy in this browser only';
|
||||
const why = (errors && errors.length) ? errors.join(' · ') : `server said ${status}`;
|
||||
return `NOT saved — ${why}`;
|
||||
}
|
||||
|
||||
// What a names-column row says about its entity. `keyed` = the timeline re-applies
|
||||
// a pose to it on every seek, so a gizmo drag will be silently reverted.
|
||||
export function rowFlags(entity) {
|
||||
const keys = (entity && entity.tracks && entity.tracks.transform) || [];
|
||||
return { keyed: keys.length > 0, selectable: !!(entity && entity.id) };
|
||||
}
|
||||
|
||||
export class TimelineUI {
|
||||
constructor(timeline, mountEl) {
|
||||
this.tl = timeline;
|
||||
@ -128,6 +254,10 @@ export class TimelineUI {
|
||||
this._seq = null; // FILM panel: the sequence doc being edited
|
||||
this._seqName = null;
|
||||
this._filmBusy = false; // one film render at a time
|
||||
this._rendering = false; // …and never at the same time as a ⏺ scene render
|
||||
this._undoMark = null; // undo depth at mousedown → a drag collapses to one entry
|
||||
this._pickMenu = null; // Cameras-row camera picker (Escape/away close it — _pickClose)
|
||||
this._pickAway = null;
|
||||
this._injectCSS();
|
||||
this._build();
|
||||
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
|
||||
@ -137,7 +267,10 @@ export class TimelineUI {
|
||||
this._wasPlaying = this.tl.playing;
|
||||
});
|
||||
this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b
|
||||
if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
|
||||
if (this.stage.onChange) this.stage.onChange((en) => this._stageChanged(en));
|
||||
// the names column IS the outliner (there is no other list of what is in the
|
||||
// scene) — keep its highlight in step with the stage's selection.
|
||||
if (this.stage.onSelect) this.stage.onSelect((en) => { this._selId = (en && en.id) || null; this._markSel(); });
|
||||
this._syncRows();
|
||||
this.draw();
|
||||
}
|
||||
@ -182,12 +315,19 @@ export class TimelineUI {
|
||||
<button data-act="audio" title="add audio clip at playhead">🎵</button>
|
||||
<button data-act="autocut" title="auto-cut: put camera cuts on the downbeats (needs a beat grid + 2 cameras)">✂</button>
|
||||
<button data-act="film" title="FILM: shot list / sequences / render a multi-shot film">🎬</button>
|
||||
<select class="rsize" title="render size">
|
||||
<option value="1920x1080">1920×1080</option>
|
||||
<option value="1280x720">1280×720</option>
|
||||
<option value="854x480">854×480</option></select>
|
||||
<button data-act="render" title="render THIS scene to mp4 with its audio">⏺ Render</button>
|
||||
<span class="rstat"></span>
|
||||
<span class="spring"></span>
|
||||
<button data-act="help" title="keyboard shortcuts (?)">?</button>
|
||||
<button data-act="tmpl" title="start a new scene from a starter template">New…</button>
|
||||
<button data-act="save">Save</button>
|
||||
<button data-act="load">Load</button>`;
|
||||
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 = '<div class="empty">no saved scenes on the server</div>';
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
261
scripts/test_render_client.mjs
Normal file
261
scripts/test_render_client.mjs
Normal file
@ -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);
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user