autoCutPlan()/autoCut(): given a beat grid and 2+ cameras, place camera cuts
on grid downbeats at a chosen bar cadence, cycling cameras so the same angle
never lands twice running, with a phase offset so a from-the-playhead run
doesn't open on the camera already live. Cut times come straight out of
gridTimes('bar') so a placed t IS a downbeat rather than a rounded neighbour.
Applied inside group() = one undo; refusals throw rather than half-apply.
UI: a scissors popover with cadence, range, per-camera checkboxes and a live
count that is literally the plan (previewed count == applied count).
FILM panel gains 'align to beat': snaps every shot's in/out to the nearest
downbeat, never reorders, clamps inside the scene, returns collapsing shots
untouched with a skipped list, and always names which grid it used.
Verified on scenes/music-video: 4 cuts at exact downbeats 3.749s apart
(2 bars at 128.02 BPM), cam2->cam3->cam4->cam1, one undo restores exactly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
36 KiB
Lane B log — append-only, newest at bottom
Format per session:
## <date> session N
DONE: …
DECISIONS: …
BLOCKED/REQUESTS: …
NEXT: …
2026-07-18 session 1
DONE: M1 complete. All files under scenegod/web/.
timeline.js— model + clock, no DOM, no three import (self-contained quat/euler/slerp math sonodecan run it).load/toJSONlossless round-trip (unknown entity fields preserved via structuredClone + a WeakMap for cached quats).evaluate(t): transform (pos/scale lerp, rot slerp→euler, easing incl. step-hold), params (num lerp, color sRGB lerp), clip blocks (set-time exact scrubbing, fade-out ramp), camera cuts (fires only on change). Clock:play()rAF deltas,seekclamp,step(frame)pure/deterministic for Lane C render. Mutators addKey/moveKey/deleteKey/ addClipBlock/moveClipBlock/trimClipBlock/addCut + undo stack (undo()wired; Ctrl+Z hooked in tlui).capturekeywindow listener present. Clips needawait tl.preload()before evaluate (prepareClip is async).tlui.js— canvas panel into#timeline. Ruler scrub, drag playhead, space=play/pause, dblclick lane=add key from stage state, drag key (snap to frame, Alt=free), right-click=delete, drag clip block/edge, Home/End, Ctrl+Z. Scene bar name/dur + Save/Load (POST/GET/scenes/{name}, localStorage fallback). Names column auto-syncs tostage.entities()viaonChange.stagestub.js— full §4.2 Stage as plain objects; records effects intostage.applied[]. Dev-only, delete at SYNC 1.timeline_test.mjs—node scenegod/web/timeline_test.mjs→ green (midpoint interp, step-ease hold, clip local time @3 frames, camera flip, lossless round-trip, unknown-field preservation).tldev.html— dev-only browser harness. Verified in browser: scrub moves entities (interp live in log), play runs to duration + auto-stops, camera cut flips camA→camB at t=3, keys/clips/cuts render. Delete at SYNC 1. DECISIONS:- Timeline imports NO three.js — inlined euler↔quat + slerp (~30 lines) so the node self-check needs zero deps (there is no node_modules/three in this stack). setTransform rot is euler (matches entityTransform format).
- One
<canvas>for all lanes (not canvas-per-lane); hit-testing via a hitbox list rebuilt each draw. Simpler, fast enough at this scale. - CSS injected from tlui.js via a
<style id=laneB-tl-css>rather than creatingweb/style.css— that file is Lane A's and didn't exist yet. Move into a/* === LANE B === */block at SYNC if John wants it centralized. (See REQUEST below.) - M1 crossfade = fade-OUT only; cross-block fade-IN is M2 "crossfade polish" per the milestone list. Marked with a ponytail comment in timeline.js. BLOCKED/REQUESTS:
- None blocking. Note for orchestrator/Lane A: my timeline styles live in
an injected
<style>in tlui.js. If you'd rather they sit in style.css, say so and I'll emit a/* === LANE B === */block for Lane A to paste (I won't edit style.css directly — ownership). - Lane A's files (index.html, stage.js, room3d.js, dock.js, style.css) are present untracked in the shared working dir but uncommitted — I staged ONLY my 5 files, left theirs alone. NEXT: Hold at M1 boundary. At SYNC 1 the stub swaps for the real Stage (one-line change in the page import) and tldev.html/stagestub.js get deleted. M2 (locked): clip crossfade polish, params/cut lane UI, snapping config, undo Ctrl+Z already stubbed, box-select multi-drag.
2026-07-18 session 2 (M2)
DONE: M2 per orchestrator update. SYNC findings fixed first, then polish.
- SYNC1 #3a/#3b: added
Timeline.onLoad(cb);load()fires it → TimelineUI rebuilds rows + refreshes name/duration fields. Verified with the exact repro (UI built against empty stage, thenload()a 2-entity scene → rows now appear, scene bar updates). - SYNC1 #4 clipdrop:
scenegod:clipdrop {id,path,clipIndex}→addClipDropdrops a block at the playhead,out = clip.duration(from preload), autofade:0.25where it abuts a neighbour. Verified via dispatched event. - Crossfade fade-IN (was fade-OUT only): a predecessor's fade pre-rolls the
successor's playback start earlier by
pred.fadeso local time is continuous across the boundary; weights ramp 1→0 / 0→1 and sum to 1 (asserted in test). - Params lanes (per-key hue), camera-cut lane add via dblclick on Cameras row (uses active cam / first camera), snap toggle in scene bar (Alt still = free), box-select + multi-drag + Delete of keys, selection highlight, fade wedge drawn on clip blocks, panel grows to content height (was fixed 200px → clipped the Cameras row once params rows were added).
- timeline_test.mjs extended: onLoad fires once, addClipDrop out=duration, crossfade weights sum to 1. All green. DECISIONS / BUG:
- tlui reads tracks from the TIMELINE, not the stage entity. Found mid-
verify:
load()structuredClones the scene, so timeline.scene.entities and stage entities are SEPARATE objects. Drawing from stage meant clipdrop/ addKey (which mutate the timeline copy) never appeared. Repointed all draw methods to_te(id) = tl.scene.entities.find(id). Rows now built from the timeline list merged with any stage-only (dock-added, not-yet-keyframed) entities. Per PLAN the timeline owns tracks — this is the correct source. - Grow-to-content height, no scroll yet (ponytail: add max-height+scroll only if a scene ever has enough entities to dominate the viewport). BLOCKED/REQUESTS:
- Gap for the orchestrator/Lane A to note: there's no bridge for a
dock-added stage entity to enter
timeline.scene.entities, so a freshly dropped character can't be keyframed until a scene load. tlui shows it as a row (empty tracks) but mutators need the entity present. If Lane A's dock should register new entities with the timeline, we need atimeline.addEntity(desc)(or the dock callsstage.captureState()→timeline.load). Flagging for SYNC 2 — not building it uninvited. - Verified with a throwaway harness (
_check.html) served on :8099; deleted, NOT committed (orchestrator removed the M1 tldev.html deliberately). NEXT: Hold at M2. Remaining M2 polish if a session 3 is granted: undo already wired to Ctrl+Z; could add snapping increment config + keyable-param add UI (currently params keys come from Lane A capture/inspector). Await SYNC 2.
2026-07-18 session 3 (M2 polish)
DONE: All three orchestrator session-3 items.
- Absorbed
_mirror(orchestrator's seam) — reviewed; it upserts dock-added stage entities intoscene.entitieswith empty tracks and drops them whenstage.getEntity(id)===null. Fixed the STUB to make removal testable:removeEntitynow firesonChangeWITH the removed entity object (was firingnull, so_mirror(null)early-returned and never removed). Added timeline_test.mjs lifecycle case: dock-add → keyframe → remove, asserting entity+empty-tracks appear then vanish. setDuration(x)mutator: clamps the playhead into range, KEEPS keys past the new end (lossless), notifies the UI (reuses onLoad → syncRows + refreshBar). Wired the dur field to it — plainscene.duration = xskipped the bar refresh. Test asserts clamp + key retention + notify.- Snap-increment select (frame / 0.1 / 0.25 / 1s) in the scene bar;
_snaprounds to the chosen step (Alt still = free). Keyable-param add: dblclick a params lane captures the entity's current param values as keys (skipstype/boolean descriptors) — mirrors the transform-capture dblclick. DECISIONS:
- params lane now shows only when the entity has ≥1 param key OR kind is
camera/light.
_mirrorseedstracks.params=[](truthy), which was giving every mirrored character an empty params lane; tightened totracks?.params?.length > 0 || camera|light. - setDuration reuses the onLoad callback list as its "refresh UI chrome" hook
rather than adding a second callback channel (lazy; onLoad handler already
does syncRows+refreshBar+draw).
BLOCKED/REQUESTS: none. Verified with a throwaway harness (
_check.html, served :8099, deleted, NOT committed). timeline_test.mjs green. NEXT: M2 polish complete. Undo already wired (Ctrl+Z). Open for M4 timeline work when unlocked (audio tracks on the timeline UI + Web Audio sync, per PLAN §5 M4) or any SYNC 3 fixes. Holding until orchestrator flips M4.
2026-07-18 session 4 (M4-B: audio + visemes)
DONE: M4-B all three items.
- Audio rows in tlui from scene.audio[]: one lane per clip below Cameras, green block at the DECODED buffer width (fallback width until decoded), gain in the name column, drag = offset (moveAudio), right-click = delete. 🎵 button reads /assets/tree audio category into a small floating menu → addAudio at the playhead.
- Web Audio draft playback synced to the master clock: lazy AudioContext, decode+cache buffers (/assets/file), _audioStart schedules AudioBufferSourceNodes at (start - time) with per-clip gain, _audioStop on pause/natural-end, user seeks reschedule (_seek helper). step()/render never touch audio — server muxes from scene.audio.
- morphs track kind: _evalMorphs lerps per viseme name → stage.setMorph;
importRhubarb(id, json, t0) maps mouthCues → morph keys; tlui shows a
"↳ visemes" lane guarded by stage.visemeTargets(id).length > 0.
Model (timeline.js, stagestub.js, timeline_test.mjs) committed ba29d0c;
tests cover morph lerp, rhubarb keys, audio mutators+undo. All green.
VERIFIED (real server :8020, real assets): 🎵 picker listed + added
audio/test/tone.wav, buffer decoded (8s), green block drawn, Web Audio
scheduled/rescheduled/stopped (ctx running), viseme lane rendered
importRhubarb keys, setMorph fired while scrubbing. Contract match confirmed:
real stage.js ships setMorph(id,name,weight) + visemeTargets(id) (returns
Object.keys(visemes)) — my
.length>0guard works against both. DECISIONS:
- Audio duration is a client draw concern (decode+cache in tlui), NOT stored in the {path,start,gain} schema — server already knows durations at mux.
- importRhubarb = simple per-cue ramp (shape 1 at cue start → 0 at end); hold/crossfade tuning deferred (ponytail marker in timeline.js) until it looks wrong on a real blendshape character.
- Audio blocks can extend past scene.duration (tone.wav is 8s in a 6s scene) — left as-is; the ruler just clips visually. Trim is a later nicety. BLOCKED/REQUESTS:
- Ownership note for orchestrator: my tlui.js M4-B changes (+146 lines)
were committed inside Lane A's
df4e06f [laneA] M4-A ..., not a [laneB] commit — Lane A's commit swept my then-uncommitted tlui.js working changes along with their dock.js/stage.js. Code is intact + verified; only the attribution is off. Heads-up that our git hygiene let a cross-lane file ride in on another lane's commit; agit add <own files>(not-A) avoids it. Nothing for me to fix. NEXT: M4-B complete. Open items if a session 5 lands: audio block trim/clip to duration, viseme import UI button (currently importRhubarb is API-only; Lane C has a /rhubarb endpoint — could wire a "lip-sync from audio" button that POSTs the clip and feeds the result to importRhubarb). Holding for SYNC/next orchestrator update.
2026-07-20 session 5 (M5 director-mode B + backlog, M6 video-follow)
DONE: All three M5 items + the M6 item, headless-verified.
scenegod:directlistener (timeline.js ctor) →applyDirect(detail): shot → transform+fov keys on the camera at playhead + addCut; light → param keys; mark → transform key (walkTo → current/prevPos key at playhead + mark key at playhead+2s). Newgroup(fn)collapses every mutator push inside one op into ONE undo entry — one Ctrl+Z reverts a whole preset.importRhubarbis now grouped too. Payload coordination: presets.js landed mid-session (untracked, Lane A in flight), so I read it — Lane A's REAL payloads differ from the PLAN-stated contract: values are spread at TOP level ({op:'shot', camId, pos, rot, fov,…}— notransform:{}wrapper), and light is{sun:{color,intensity}, ambient:{…}, bg, sunId, ambientId}(per-entity values, not a flatparamsmap; bg keys onto the ambient entity; applyDirect ALSO captures the sun-boom transform from the stage since the payload lacks sunPos rot). applyDirect accepts BOTH shapes (contract + live); tests cover both.- 🗣 button on every audio row (tlui) →
GET /rhubarb?path=→importRhubarb(charId, json, clip.start). Character picker (_choosepromise-menu) when >1 viseme-bearing character, guard toast when 0,_toastshows the 503 install hint from FastAPIdetail. Viseme lane resyncs after import. - Backlog: (a) wheel fix — plain wheel is NOT captured (page scrolls);
Cmd/Ctrl+wheel (incl. trackpad pinch) zooms 1–64× around the cursor,
horizontal wheel pans when zoomed; all geometry through _t2x/_x2t so
keys/blocks/playhead/hitboxes just work. (b) audio trim: drag the block's
right edge →
trimAudio(clip, {in,out})(source-relative, like clip blocks; absent = untrimmed so the {path,start,gain} schema is unchanged for untouched clips); draft Web Audio playback honors in/out; ✂ badge on trimmed blocks; undo restores pristine (fields deleted, not undefined). - M6:
_evalVideoin evaluate for backdrop/screen entities via new nullable stage seamentityVideo(id):currentTime = max(0, t-videoStart) % durationONLY when drift >50ms; play/pause follows the clock (pause() now re-evaluates so videos freeze); play() promise .catch()ed, never awaited — step() stays sync (render determinism is stage.syncVideos, Lane A/C). Files: timeline.js, tlui.js, stagestub.js (StubVideo records currentTime sets; entityVideo null for image backdrops — honest-nullable), timeline_test.mjs.node scenegod/web/timeline_test.mjs→ all green (shot-via-real-EventTarget-dispatch + one-undo revert, generic + Lane A light shapes, walkTo 2 keys, Lane A mark shape, rhubarb group undo, trim undo, video drift-gate/modulo/videoStart-clamp/play-pause, nullable video). DECISIONS:
- group() splices the undo stack rather than a begin/end transaction API — mutators stay dumb, nesting works, zero cost when fn pushes ≤1 entry.
- Sun transform key on light presets comes from stage.entityTransform at event time (Lane A setTransforms before firing) — payload has sunPos but no rot; reading the stage gets both without a payload change.
- Video seek target clamps negative (t < videoStart → hold frame 0); wrap uses plain % (t-videoStart ≥ 0 after clamp).
- StubVideo lives in a stage-side Map, NOT on the entity object, so captureState/clone stays clean. BLOCKED/REQUESTS:
- Lane A: walk-to-mark can't work from the current payload — applyMark
setTransforms the subject BEFORE firing, so "current pos" at event time is
already the mark. When you add a walk mark, either include
prevPos(+walkTo:true) in the detail (applyDirect already honors both) or skip the pre-apply for walk marks. Also: no walk mark exists in grammar.js MARKS yet. - Lane C: audio trim adds optional
in/out(source-relative seconds) to scene.audio clips — mux should honor them (ffmpeg -ss/-t per clip); absent = whole file (schema unchanged for untrimmed clips). Client draft playback already honors them. - Orchestrator: PLAN §4.1 audio schema + §4.2 Stage API could gain the
two seams now in live use: audio {in?, out?} and
entityVideo(id) -> HTMLVideoElement|null(Lane A implements on real Stage for M6-A). NEXT: SYNC — browser-verify DIRECT panel button → keys+cut land + single undo; 🗣 on real vo.wav → viseme keys on a blendshape character (and the 503 toast with rhubarb absent); Cmd+wheel zoom feel; video backdrop scrub-follow once Lane A ships entityVideo. Holding at M6-B boundary.
2026-07-25 session 6 (M7-B: the way in — templates + shortcuts overlay)
DONE: both required items + one backlog item, all headless-verified.
- New from template (scene bar, left of Save/Load —
tlui.js:122)._pickTemplate()(tlui.js:585) —GET templates→ floating.tl-menu.tmpl(same pattern as the 🎵 picker) with title + description + thumb (assets/file?path=,img.onerror→ text-only row). All URLs RELATIVE.newFromTemplate(name)(tlui.js:616): warn-before-clobber viatl.hasContent()→confirm(), thenGET templates/{name}→tl.applyScene(json),_decodeAll(),draw(). Any failure (fetch, !ok, apply) toasts through_toast(window.sgToast still points at it). ?shortcuts overlay (tlui.js:633_toggleHelp/_helpClose, CSS.tl-helpat tlui.js:48). Opens with?or the?button (tlui.js:121), closes on Escape / click-outside / ✕. Three columns — STAGE, TIMELINE, SCENE BAR — written by READING the code, not from memory: stage.js:118-122 (W/E/R gizmo, selection-gated), dock.js drops, and every binding in my_key/_down/_dbl/_ctx/_wheel. Deliberately NOT listed: right-click on a clip block (only keys/cuts/audio blocks delete), left-edge trim on animation clip blocks (only the right edge is a hit target).- Backlog: audio HEAD trim — dragging an audio block's LEFT edge now eats
into the source (
inandstartmove together so the audible part stays put) instead of being dead space.timeline.js:trimAudioIn(grouped: one undo for the pair, clamps at the file head, at t=0, and 0.05s beforeout); tluiaedgehits now carryside('in' | 'out'). Model seams added to timeline.js:applyScene(json)(timeline.js:173) — stage.applyState → load → preload, the ONE apply path now shared by Load and New-from-template; andhasContent()(timeline.js:185). TEST:node scenegod/web/timeline_test.mjs→ green, +5 new blocks: hasContent (empty / cuts-only / audio-only / dock-added entity), applyScene happy path (entities on stage, tracks in the model, onLoad once, camera live, clip preloaded and playing at t=1,templatekey stripped, caller's json not mutated), a loop that applies EVERY shippedtemplates/*.jsonthrough applyScene against the stub ("3 shipped templates applied clean"), and head-trim (pair + single undo + both clamps).node --checkclean on timeline.js, tlui.js, stagestub.js, timeline_test.mjs. DECISIONS:
tlui.load()now routes throughtl.applyScenetoo, so Load and templates cannot drift apart (the ask was "apply it the same way Load does" — made it literally the same code rather than a copy).applyScenedeletes thetemplatekey before loading. server.py:264 claims that metadata "never survives a Timeline round-trip" — it WOULD have, since load() structuredClones unknown fields on purpose (lossless round-trip is a tested feature). Stripping it in one place makes Lane C's comment true and keeps saved scenes plain scene JSON.hasContent()counts ANY entity, cut or audio clip — a dock-added, not-yet-keyframed character is still work worth a confirm. A fresh page has zero entities (Stage creates none in its ctor), so the first New… never nags. Used plainconfirm()— a modal I'd have to build is not more honest than the browser's._keynow also ignores SELECT + contentEditable targets, so?(and Space) can't be hijacked while the snap dropdown or any text field has focus.- The
?button is excluded from the overlay's click-outside handler, otherwise mousedown closed it and the click reopened it. BLOCKED/REQUESTS: - Lane C / orchestrator (nit):
GET /templatesreturns thumbs that are ASSET-relative (backdrops/video/scenegod_plates/*.jpg) — I render them viaassets/file?path=. If a future template ever ships a thumb that lives outside SCENEGOD_ASSETS, the picker will show a broken (auto-removed) image; keep thumbs inside the assets root. - Lane C (still open from session 5): audio
in/outat ffmpeg mux. Head trim makes it much easier to hit — a head-trimmed clip now renders from the file start server-side while the draft playback starts atin. NEXT: nothing required. Remaining polish if a session 7 lands: box-select multi-drag refinements (marquee currently only selects keys — clip/audio blocks and cuts are not box-selectable), and a per-drag undo coalesce (a mousemove drag pushes one undo entry per frame; Ctrl+Z walks it back a pixel at a time). Holding at the M7-B boundary.
2026-07-26 session 7 (M9-B: snap to the beat + the FILM panel)
DONE: both features, headless-verified. No git run this session (orchestrator's
instruction) — timeline.js, tlui.js, timeline_test.mjs and this log are
edited in place, uncommitted. stagestub.js needed no change this time.
M9-B1 — SNAP TO THE BEAT.
- Model (
timeline.js:578-673):setBeatGrid/clearBeatGrid(:593),beatOffset()(:600),gridTimes(kind)(:616),snapToGrid(t, kind)(:635), plus the purebeatGridFrom(res, offset)export (:678) that maps Lane C's/beatsresponse into the stored shape. - UI (
tlui.js):♪button on every audio row next to🗣(:~250,_detectBeatsat:775) —GET beats?path=→ cache per path (this._beatCache) →setBeatGrid→ snap flips tobeatand the toast says the BPM, the beat count and the confidence. beat + bar added to the snap-increment select (:156);_snap()(:477) routes those two throughtl.snapToGridand falls back to frames when no grid exists (plus a toast if you pick beat/bar with nothing detected). Grid drawing:_drawBeatGrid(:365) — beatsrgba(146,180,232,0.13), downbeatsrgba(224,175,104,0.34), over the lanes, suppressed below ~7px spacing (beats drop out first, then bars, then nothing — never a solid wash). - Because
_snapwas already the ONE snapping funnel, keys, clip blocks, audio blocks, both trim edges, camera cuts and the playhead all became musical for free. That was the whole bet in the M2 design and it paid.
M9-B2 — FILM panel (tlui.js:848-1130): collapsible .tl-film section
mounted INSIDE #timeline (_buildFilm :854, toggled by 🎬 in the scene
bar :158). Sequence dropdown + New…/Rename/Delete, computed runtime, size
picker, Save, Render film. Shot rows (_filmRow :960): scene <select> from
GET scenes (with /Ns scene length shown), in/out numbers, transition
cut|dissolve (+ seconds, only where a next shot exists), ↑ ↓ ✕. All server
talk goes through Lane C's film.js helpers (listSequences/getSequence/ saveSequence/deleteSequence/renderSequence) — no hand-rolled URLs.
_filmRender (:1100): hasContent() + a confirm that says plainly that every
shot loads its own scene and the open scene will be REPLACED; then it SAVES
first (the server renders the saved shot list, not the editor), disables the
whole panel for the duration (_filmBusySet :1045, so no second render), shows
shot 2/3 — sync2-sunset — frame 45/120 live, and ends with a link to
films/{id}/out.mp4 + a note that the stage now holds the last shot's scene.
422s from plan_sequence are unwrapped by _errText and shown in red.
TEST: node scenegod/web/timeline_test.mjs → green, +2 blocks (:394, :482).
Beat block runs against the REAL captured /beats payload for
audio/test/house128.wav (bpm 128.02, 35 beats, 8 downbeats, conf 0.865), and
asserts: no grid → snapToGrid null + nothing to draw; nearest beat over 9 time
points and nearest downbeat over 6; the 0.24/0.25s midpoint flip; bars use Lane
C's downbeat COSET (first downbeat is beats[3], not beats[0]); extrapolation past
the last beat; never snapping below 0; grid follows moveAudio and survives
trimAudioIn unmoved (start and in move together); gridTimes covers a longer
scene and stays sorted; bar lines are a subset of beat lines; a 0.12-confidence
grid still snaps and a grid alone does NOT make hasContent() true; toJSON →
load → identical snapping; clearBeatGrid leaves plain scene JSON. Film block
asserts filmRuntime equals the server's arithmetic on the shipped
demo-two-shot (11.50s), plus the last-shot-dissolve and cut-transitionDur
edge cases. node --check clean on all four files.
SERVER-SIDE CHECKS (read-only against the running :8020, nothing restarted):
validate_scene(server.py:235) inspects only version/entities/tracks/ cameraCuts — verified by calling it directly with abeatGridscene (violations: []), so the new top-level key is legal, not merely tolerated.- Full round-trip proven live: POST a scene carrying
beatGrid→ 200, GET it back → grid intact byte-for-byte. Test scene file deleted again (scenes/ holds the same four it did before). DECISIONS: - Field shape — ONE top-level
scene.beatGrid, not per-audio-clip:{path, bpm, confidence, meter, period, offset, beats[], downbeats[]}. A music video has one tempo, and "which grid am I snapping to" must have exactly one answer; per-clip grids would need a picker for a question nobody asks.beats/downbeatsstay in FILE seconds exactly as Lane C returns them (so the stored grid is comparable with a re-fetch) andoffsetrecords where the file's t=0 sat. The EFFECTIVE offset is re-derived from the audio clip with the samepathwhenever one is still on the timeline — drag the audio block and the grid comes with it; delete the clip and the storedoffsetkeeps it working. - The grid extrapolates at constant tempo outside the analysed range, in
BOTH
gridTimes(drawing) andsnapToGrid, so the lines you see and the snap you get agree everywhere. Without it, an 8s track under a 30s scene drags every late key back onto the last real beat — a trap, not a feature. Lane C's grid is explicitly constant-tempo, so this invents nothing. - Low confidence warns, never refuses (Lane C: >0.5 solid, <0.2 mush). The UI toasts "LOW confidence (0.12) — this grid may be mush" at <0.35 and snaps anyway; the model has no opinion about confidence at all.
- The fetch + per-path cache live in tlui, like every other server call in
this lane (
/rhubarb,/templates,/assets/tree);timeline.jsgot the purebeatGridFrominstead, so the response→storage contract is testable headless without stubbingfetchfor a three-line call. hasContent()deliberately ignoresbeatGrid: it is one button press to rebuild, so it is not work worth a confirm.- film.js is imported lazily (
_filmMod(), first 🎬 press) rather than at the top of tlui.js: the timeline must still mount if Lane C's client files are missing or broken, and nobody who never opens FILM should pay for render.js. filmRuntime(shots)is exported from tlui.js as a pure function so the number on screen can be pinned to the server's own formula in the node test — tlui.js touches no DOM at module scope, and the test'simport('./tlui.js')now fails loudly the day that changes.- Rename is save-then-delete (a failed save leaves the original untouched); the slug the server returns becomes the working name. BLOCKED/REQUESTS:
- Lane C / orchestrator (nice-to-have, not blocking):
/beatson a DJ mix assumes one tempo for the whole hour — Lane C already has?from=&to=windowing on its own backlog. When it lands I'd store one grid per window, or at minimum re-detect from the playhead; the current shape can grow arange:[from,to]field without breaking anything. - Lane C (still open, third session running): nothing new — audio
in/outat the mux is CLOSED as of C session 7, thanks. - Orchestrator: PLAN §4.1 could gain the two fields now in live use —
scene.beatGrid(shape above) and the already-shipped audio{in?, out?}. NEXT: nothing required. Backlog if a session 8 lands: (1) a "nudge to grid" action that re-snaps EXISTING keys/cuts to the nearest beat (today only new drags snap); (2) box-select still only grabs keys, not clip/audio blocks or cuts; (3) per-drag undo coalesce (a mousemove drag still pushes one undo entry per frame); (4) film-levelaudio[]has no UI — a sequence's soundtrack must still be edited by hand, and it is the obvious partner to beat-snapping.
2026-07-26 session 8 (M10-B: AUTO-CUT + align a film to the beat)
DONE: both, headless-verified. No git run (standing instruction) — timeline.js,
tlui.js, timeline_test.mjs and this log edited in place. stagestub.js
needed no change again. The running :8020 already serves the edits (checked with
a read-only curl: /web/timeline.js carries autoCutPlan, nothing restarted).
M10-B1 — AUTO-CUT (timeline.js:635-700, UI tlui.js:823-910).
cameraIds()(:635) — cameras in scene order, merged with any stage camera not yet mirrored (the same merge tlui's_rowsuses).autoCutPlan(opts)(:647) — PURE preview:{ok, cuts, replaced, kept, cameras, cadence, barPeriod, confidence, bpm, from, to}or{ok:false,error}. Cuts come straight out ofgridTimes('bar')(so a placed t IS a grid downbeat, not a rounded neighbour);bars.filter((_,i)=>i%cadence===0); cameras cyclecams[(i+phase)%n].autoCut(opts)(:687) — applies insidegroup()→ ONE undo;throws the plan's error rather than half-applying;seek(time)so the new cut goes live.removeCut(cut)(:459) — new undo-able mutator (replacements had to be undoable too; tlui's right-click delete still splices directly, unchanged).- UI:
✂in the scene bar → popover with cadence (1/2/4/8 bars, default 2), range (whole scene | from the playhead), a checkbox per camera, and a live "will place N cuts · one every 3.75s" line that is literallyautoCutPlan()— the previewed count and the applied count are the same call. Refusals render in that line in red with Apply disabled. Escape / click-outside close. The count re-runs on every tick, so scrubbing with "from the playhead" selected keeps it honest. TEST:node scenegod/web/timeline_test.mjs→ green, +2 blocks (:498,:646). Auto-cut block asserts against the same real house128.wav payload: 8 downbeats in a 16s scene → cadence 1/2/4/8 gives 8/4/2/1 cuts; every placedtis===a member ofgridTimes('bar'); 2 cameras strictly alternate A/B/A/B; 3 cameras cycle A/B/C/A/B/C/A/B; no camera lands twice in a row; ONE undo restores the exact priorcameraCuts(deepEqual against a pre-snapshot) and the stack grows by exactly 1; re-running replaces its own cuts (4 cuts stay 4, alltunique, same positions); a hand cut ON a downbeat is replaced, one BETWEEN downbeats is kept untouched and reported; no-grid and one-camera both throw with a clear message AND leavecameraCutsand the undo stack empty; an empty range refuses; the plan follows the audio when the block is dragged. Also run against the REALscenes/music-video.json(4 cams, saved 128.02 BPM grid): cadence 2 → 1.416:cam2 5.165:cam3 8.915:cam4 12.664:cam1, undo restores the file's original two cuts exactly.
M10-B2 — align a film's shots to the beat (timeline.js:772-793 pure,
tlui.js:1213-1252 panel). ♪ align button in the FILM head row.
- WHICH GRID: the open scene's if it has one, else the first shot scene (in
shot order) with a saved
beatGrid, fetched viascenes/{name}. The status line always names the source ("from the open scene "music-video"" / "from shot scene "x"") — "why did it snap there" must have a visible answer. alignShotsToGrid(shots, grid, off, durations)snaps eachin/outto the nearest downbeat, never reorders, and returns{shots, skipped}. A shot that would collapse is returned BYTE-IDENTICAL and listed inskipped; the panel prints "left alone: #3 (would collapse)". Runtime redraws via_filmRows(); nothing is sent to the server until the user presses Save. TEST: order preserved, both boundaries land on real grid lines,out > infor every surviving shot, the collapsing shot is untouched + reported, the caller's array is not mutated, transitions ride along, aligning twice is a no-op, and an offset grid shifts the whole alignment. Verified against both shipped sequences with the music-video grid: demo-two-shot 0-4.5|4.5-8|0-4 becomes 1.4161-5.1654|5.1654-7.04|1.4161-3.2907, 0 skipped. DECISIONS:- Pure grid helpers extracted (
beatOffsetOf,beatPeriodOf,gridTimesOf,snapToGridWith,timeline.js:716-770);beatOffset/gridTimes/snapToGridare now one-line delegates. B2 aligns shots of scenes that are NOT loaded — it has a grid and a shot list, no Timeline — and it must get bit-identical arithmetic to what the user saw while cutting. Behaviour unchanged: the whole M9 beat block still passes untouched. - Replace ON a position, keep everything else. An existing cut within half
a frame of a placed time is replaced (a duplicate 15ms away is a flash frame);
cuts elsewhere in the range are the user's edit and are NOT deleted. But they
interleave and one can repeat a camera, so the plan returns
keptand the popover says "1 of your own cuts left in place between them". Disclosure beats either silently stuttering or silently deleting. Flip to "clear the range" if you'd rather, it's a one-line change — but I'd want John to ask. - Phase offset so the run doesn't open on the camera already live. With
"from the playhead", the last cut before the range is checked; if it holds
cams[0]the cycle starts atcams[1]. Otherwise a beat-perfect run can still open with the same angle twice, which is the one thing a cut must never do. Verified live on music-video.json (cut at 0 = cam1 → run opens cam2). - No cleverness, as instructed: no random order, no energy analysis, no
variation. Every knob is on the popover and the output is ordinary
cameraCutsthe user hand-edits after. - B2 clamps by walking back a whole bar and RE-SNAPPING (
timeline.js:786), not by subtracting the bar period: Lane C rounds beats to 4dp and the period to 6dp, sob - barlands 48µs off a real downbeat. "Lands on a downbeat" has to mean the real one, and the test asserts membership at 1e-6. autoCutTHROWS on refusal instead of returning a flag: the caller already hasautoCutPlan()to check, so a thrown error can't be ignored into a half-applied edit. The popover never lets you press Apply on a bad plan anyway.- FILM align has no undo (the panel has none by design — the server doc is the state and Save is the gate); the status line says "Save to keep it". BLOCKED/REQUESTS:
- Orchestrator: PLAN §4.1 still doesn't list
scene.beatGridor audio{in?, out?}, both in live use since M9 (carried from last session). - Lane C (nice-to-have):
GET scenesreturns{name, duration}but not whether a scene HAS a beatGrid. B2 has to fetch whole scene JSONs one at a time to find one. AhasGrid/bpmfield on the list would make the FILM panel's grid hunt one request instead of N. NEXT: nothing required. Backlog if a session 9 lands, in order: (1) "nudge to grid" for EXISTING keys/cuts (today only new drags and auto-cut are musical); (2) box-select still only grabs keys, not clip/audio blocks or cuts; (3) per-drag undo coalesce (a mousemove drag still pushes one entry per frame); (4) film-levelaudio[]has no UI — the obvious partner to ♪ align.