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>
1151 lines
57 KiB
JavaScript
1151 lines
57 KiB
JavaScript
// timeline.js — SCENEGOD master clock, tracks, keyframe evaluation (Lane B).
|
|
// Talks to Stage ONLY through the PLAN §4.2 API. NO DOM, NO three.js import,
|
|
// so it runs headless under `node` for timeline_test.mjs. All the vector /
|
|
// quaternion math it needs is inlined below (a few lines each).
|
|
|
|
// ---- tiny math (self-contained; euler XYZ radians) --------------------------
|
|
const lerp = (a, b, u) => a + (b - a) * u;
|
|
const lerpArr = (a, b, u) => a.map((v, i) => lerp(v, b[i], u));
|
|
|
|
function eulerToQuat([x, y, z]) { // XYZ order, matches three
|
|
const c1 = Math.cos(x / 2), s1 = Math.sin(x / 2);
|
|
const c2 = Math.cos(y / 2), s2 = Math.sin(y / 2);
|
|
const c3 = Math.cos(z / 2), s3 = Math.sin(z / 2);
|
|
return [
|
|
s1 * c2 * c3 + c1 * s2 * s3,
|
|
c1 * s2 * c3 - s1 * c2 * s3,
|
|
c1 * c2 * s3 + s1 * s2 * c3,
|
|
c1 * c2 * c3 - s1 * s2 * s3,
|
|
];
|
|
}
|
|
function quatToEuler([x, y, z, w]) { // XYZ order
|
|
const m11 = 1 - 2 * (y * y + z * z), m12 = 2 * (x * y - z * w), m13 = 2 * (x * z + y * w);
|
|
const m22 = 1 - 2 * (x * x + z * z), m23 = 2 * (y * z - x * w);
|
|
const m33 = 1 - 2 * (x * x + y * y);
|
|
const ey = Math.asin(Math.max(-1, Math.min(1, m13)));
|
|
let ex, ez;
|
|
if (Math.abs(m13) < 0.9999999) { ex = Math.atan2(-m23, m33); ez = Math.atan2(-m12, m11); }
|
|
else { ex = Math.atan2(m22 !== undefined ? 2 * (y * z + x * w) : 0, m22); ez = 0; }
|
|
return [ex, ey, ez];
|
|
}
|
|
function slerp(a, b, u) { // quaternion slerp, sign-safe
|
|
let [ax, ay, az, aw] = a, [bx, by, bz, bw] = b;
|
|
let cos = ax * bx + ay * by + az * bz + aw * bw;
|
|
if (cos < 0) { bx = -bx; by = -by; bz = -bz; bw = -bw; cos = -cos; }
|
|
if (cos > 0.9995) { // nearly parallel → nlerp
|
|
const q = [lerp(ax, bx, u), lerp(ay, by, u), lerp(az, bz, u), lerp(aw, bw, u)];
|
|
const n = Math.hypot(...q) || 1;
|
|
return q.map((v) => v / n);
|
|
}
|
|
const t = Math.acos(cos), s = Math.sin(t);
|
|
const wa = Math.sin((1 - u) * t) / s, wb = Math.sin(u * t) / s;
|
|
return [ax * wa + bx * wb, ay * wa + by * wb, az * wa + bz * wb, aw * wa + bw * wb];
|
|
}
|
|
|
|
function ease(u, kind) {
|
|
switch (kind) {
|
|
case 'step': return 0; // hold start value across segment
|
|
case 'in': return u * u * u;
|
|
case 'out': return 1 - Math.pow(1 - u, 3);
|
|
case 'inout': return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
|
|
case 'linear': default: return u;
|
|
}
|
|
}
|
|
|
|
// color helpers for param track (sRGB lerp is fine for v1 per B2)
|
|
const isColor = (v) => typeof v === 'string' && v[0] === '#';
|
|
function hexToRgb(h) { const n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
|
|
function rgbToHex([r, g, b]) { return '#' + [r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join(''); }
|
|
|
|
// find bracketing keys in a t-sorted array. ponytail: linear scan — key arrays
|
|
// are tiny (handfuls); swap for binary search only if a lane ever profiles hot.
|
|
function bracket(keys, t) {
|
|
if (keys.length === 0) return null;
|
|
if (t <= keys[0].t) return [keys[0], keys[0], 0];
|
|
const last = keys[keys.length - 1];
|
|
if (t >= last.t) return [last, last, 0];
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (t >= keys[i].t && t < keys[i + 1].t) {
|
|
const span = keys[i + 1].t - keys[i].t;
|
|
return [keys[i], keys[i + 1], span > 0 ? (t - keys[i].t) / span : 0];
|
|
}
|
|
}
|
|
return [last, last, 0];
|
|
}
|
|
|
|
// ---- Timeline ---------------------------------------------------------------
|
|
export class Timeline {
|
|
constructor(stage) {
|
|
this.stage = stage;
|
|
this.scene = { version: 1, name: 'untitled', fps: 30, duration: 10, entities: [], cameraCuts: [], audio: [] };
|
|
this.time = 0;
|
|
this.playing = false;
|
|
this._tickCbs = [];
|
|
this._loadCbs = [];
|
|
this._raf = null;
|
|
this._last = 0;
|
|
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._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._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 && !this._locked(`director "${e.detail.op}"`)) {
|
|
try { this.applyDirect(e.detail); } catch (err) { this._fail('direct', err); }
|
|
}
|
|
});
|
|
}
|
|
// SYNC2 seam: mirror live stage adds/removes into scene.entities so
|
|
// dock-added entities can be keyframed without a scene load.
|
|
if (stage && stage.onChange) stage.onChange((en) => this._mirror(en));
|
|
}
|
|
|
|
// One place for "the user asked for something and it didn't happen": console + a
|
|
// toast if the UI provides one (tlui installs window.sgToast). Never swallow.
|
|
_fail(what, err) {
|
|
console.error(`[scenegod:${what}]`, err);
|
|
const msg = (err && err.message) || String(err);
|
|
if (typeof window !== 'undefined' && typeof window.sgToast === 'function') window.sgToast(`${what}: ${msg}`);
|
|
return null;
|
|
}
|
|
// The positive counterpart to _fail: say what an op actually did. A timeline op
|
|
// that succeeds silently — especially one that REPLACES existing keys — is how
|
|
// users stop trusting the button.
|
|
_say(msg) {
|
|
if (typeof window !== 'undefined' && typeof window.sgToast === 'function') window.sgToast(msg);
|
|
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;
|
|
const i = list.findIndex((x) => x.id === en.id);
|
|
if (!this.stage.getEntity(en.id)) { // gone from stage → drop entity + its tracks
|
|
if (i >= 0) list.splice(i, 1);
|
|
return;
|
|
}
|
|
if (i >= 0) return; // known — timeline already owns its tracks
|
|
list.push({
|
|
id: en.id, kind: en.kind, label: en.label,
|
|
source: en.source ? structuredClone(en.source) : { type: 'upload' },
|
|
params: en.params ? structuredClone(en.params) : {},
|
|
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; }
|
|
get duration() { return this.scene.duration; }
|
|
get entities() { return this.scene.entities; }
|
|
get cameraCuts() { return this.scene.cameraCuts; }
|
|
|
|
// ---- load / save (lossless round-trip) ----
|
|
load(sceneJson) {
|
|
this.scene = structuredClone(sceneJson);
|
|
if (this.scene.fps == null) this.scene.fps = 30;
|
|
if (this.scene.duration == null) this.scene.duration = 10;
|
|
this.scene.entities = this.scene.entities || [];
|
|
this.scene.cameraCuts = this.scene.cameraCuts || [];
|
|
this.scene.audio = this.scene.audio || [];
|
|
for (const e of this.scene.entities) this._sortTracks(e);
|
|
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
|
|
this._clipActions.clear();
|
|
this._activeCam = undefined;
|
|
this.seek(0);
|
|
for (const cb of this._loadCbs) cb(this.scene); // UI: rebuild rows + refresh scene bar (SYNC1 #3)
|
|
return this;
|
|
}
|
|
onLoad(cb) { this._loadCbs.push(cb); }
|
|
// 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
|
|
// two paths can never drift apart. `template` (Lane C's presentation metadata:
|
|
// title/description/thumb/order) is dropped here — it must never ride into a
|
|
// saved scene through the lossless round-trip.
|
|
async applyScene(json) {
|
|
const scene = structuredClone(json);
|
|
delete scene.template;
|
|
// _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;
|
|
}
|
|
|
|
// "Is there anything here the user would hate to lose?" — the confirm-before-
|
|
// clobber predicate for New-from-template. Any entity at all counts (a dock-
|
|
// added, not-yet-keyframed character is still work), as do cuts and audio.
|
|
hasContent() {
|
|
const s = this.scene;
|
|
if ((s.entities || []).length) return true;
|
|
if ((s.cameraCuts || []).length) return true;
|
|
if ((s.audio || []).length) return true;
|
|
return false;
|
|
}
|
|
|
|
// Programmatic duration change (dur field or code). Keys past the new end are
|
|
// kept (lossless) but unreachable; clamp the playhead and refresh the UI —
|
|
// plain `scene.duration = x` skips both. Reuses the onLoad hook to redraw +
|
|
// resync the scene bar.
|
|
setDuration(x) {
|
|
this.scene.duration = Math.max(0.1, +x || 0.1);
|
|
if (this.time > this.scene.duration) this.time = this.scene.duration;
|
|
for (const cb of this._loadCbs) cb(this.scene);
|
|
this.seek(this.time);
|
|
return this.scene.duration;
|
|
}
|
|
|
|
// Lane A clip-drop: fetch the clip (for its duration), drop a block at the
|
|
// playhead, add a default crossfade where it abuts a neighbour, preload.
|
|
async addClipDrop({ id, path, clipIndex = 0 }) {
|
|
const clip = await this.stage.prepareClip(id, path, clipIndex);
|
|
const dur = (clip && clip.duration) || 1;
|
|
this.addClipBlock(id, { path, clipIndex, start: this.time, in: 0, out: dur, loop: 1, fade: 0 });
|
|
this._setAbutFades(id);
|
|
await this.preload();
|
|
this.seek(this.time); // re-evaluate + redraw
|
|
}
|
|
_setAbutFades(id) { // 0.25s fade on any block that abuts its successor
|
|
const blocks = (this._tracks(id).clips) || [];
|
|
for (let i = 0; i < blocks.length - 1; i++) {
|
|
const b = blocks[i], n = blocks[i + 1];
|
|
const end = b.start + ((b.out - b.in) || 0) * (b.loop || 1);
|
|
if (Math.abs(end - n.start) < 1e-3 && !b.fade) b.fade = 0.25;
|
|
}
|
|
}
|
|
|
|
_sortTracks(e) {
|
|
const tr = e.tracks;
|
|
if (!tr) return;
|
|
if (tr.transform) tr.transform.sort((a, b) => a.t - b.t);
|
|
if (tr.params) tr.params.sort((a, b) => a.t - b.t);
|
|
if (tr.morphs) tr.morphs.sort((a, b) => a.t - b.t);
|
|
if (tr.clips) tr.clips.sort((a, b) => a.start - b.start);
|
|
}
|
|
_quat(key) {
|
|
let q = this._qcache.get(key);
|
|
if (!q) { q = eulerToQuat(key.rot || [0, 0, 0]); this._qcache.set(key, q); }
|
|
return q;
|
|
}
|
|
|
|
// ---- clock ----
|
|
onTick(cb) { this._tickCbs.push(cb); }
|
|
play() {
|
|
if (this.playing || typeof requestAnimationFrame === 'undefined') return;
|
|
this.playing = true;
|
|
this._last = performance.now();
|
|
const loop = (now) => {
|
|
if (!this.playing) return;
|
|
const dt = (now - this._last) / 1000;
|
|
this._last = now;
|
|
let t = this.time + dt;
|
|
if (t >= this.duration) { t = this.duration; this.playing = false; }
|
|
this.seek(t);
|
|
if (this.playing) this._raf = requestAnimationFrame(loop);
|
|
};
|
|
this._raf = requestAnimationFrame(loop);
|
|
}
|
|
pause() {
|
|
this.playing = false;
|
|
if (this._raf) cancelAnimationFrame(this._raf);
|
|
this._raf = null;
|
|
if (this.stage.entityVideo) this.evaluate(this.time); // M6: videos pause with the clock
|
|
}
|
|
seek(t) {
|
|
this.time = Math.max(0, Math.min(this.duration, t));
|
|
this.evaluate(this.time);
|
|
for (const cb of this._tickCbs) cb(this.time);
|
|
}
|
|
step(frame) { this.time = Math.max(0, Math.min(this.duration, frame / this.fps)); this.evaluate(this.time); return this.time; }
|
|
|
|
// ---- clip preload (async fetch/retarget once; evaluate stays sync) ----
|
|
async preload() {
|
|
for (const e of this.entities) {
|
|
const clips = e.tracks && e.tracks.clips;
|
|
if (!clips) continue;
|
|
const mixer = this.stage.entityMixer(e.id);
|
|
for (const b of clips) {
|
|
if (this._clipActions.has(b)) continue;
|
|
const clip = await this.stage.prepareClip(e.id, b.path, b.clipIndex || 0);
|
|
const action = mixer.clipAction(clip);
|
|
action.play(); action.paused = true; action.enabled = false; action.weight = 0;
|
|
this._clipActions.set(b, { action, clip });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- evaluation ----
|
|
evaluate(t) {
|
|
for (const e of this.entities) {
|
|
if (e.kind === 'camera' || e.tracks) this._evalTransform(e, t);
|
|
this._evalParams(e, t);
|
|
if (e.tracks && e.tracks.morphs) this._evalMorphs(e, t);
|
|
if (e.tracks && e.tracks.clips) this._evalClips(e, t);
|
|
if ((e.kind === 'backdrop' || e.kind === 'screen') && this.stage.entityVideo) this._evalVideo(e, t);
|
|
}
|
|
this._evalCameraCuts(t);
|
|
}
|
|
|
|
// M6 FLOW VIDEO PLATES: video backdrops/screens follow the master clock.
|
|
// Cheap scrub-follow — seek only when drift >50ms; play/pause with the
|
|
// clock. NEVER awaits (step() stays sync — the deterministic render path
|
|
// awaits stage.syncVideos(t) per frame instead, Lane A/C's seam).
|
|
_evalVideo(e, t) {
|
|
const v = this.stage.entityVideo(e.id);
|
|
if (!v) return; // nullable: image backdrop / not loaded
|
|
const dur = v.duration;
|
|
if (Number.isFinite(dur) && dur > 0) {
|
|
const want = Math.max(0, t - ((e.params && e.params.videoStart) || 0)) % dur;
|
|
if (Math.abs(v.currentTime - want) > 0.05) v.currentTime = want;
|
|
}
|
|
if (this.playing && v.paused) { const p = v.play(); if (p && p.catch) p.catch(() => {}); }
|
|
else if (!this.playing && !v.paused) v.pause();
|
|
}
|
|
|
|
_evalMorphs(e, t) { // viseme/blendshape weights → stage.setMorph (M4-B)
|
|
const keys = e.tracks && e.tracks.morphs;
|
|
if (!keys || !keys.length || !this.stage.setMorph) return;
|
|
const byKey = new Map();
|
|
for (const k of keys) { if (!byKey.has(k.key)) byKey.set(k.key, []); byKey.get(k.key).push(k); }
|
|
for (const [name, ks] of byKey) {
|
|
const [k0, k1, u] = bracket(ks, t);
|
|
const w = k0 === k1 ? k0.value : lerp(k0.value, k1.value, ease(u, k0.ease));
|
|
this.stage.setMorph(e.id, name, w);
|
|
}
|
|
}
|
|
|
|
_evalTransform(e, t) {
|
|
const keys = e.tracks && e.tracks.transform;
|
|
if (!keys || keys.length === 0) return; // 0 keys → leave rest pose alone
|
|
const br = bracket(keys, t);
|
|
const [k0, k1, u] = br;
|
|
if (k0 === k1) { this.stage.setTransform(e.id, { pos: k0.pos, rot: k0.rot, scale: k0.scale }); return; }
|
|
const eu = ease(u, k0.ease);
|
|
const pos = lerpArr(k0.pos, k1.pos, eu);
|
|
const scale = typeof k0.scale === 'number'
|
|
? lerp(k0.scale, k1.scale, eu)
|
|
: lerpArr(k0.scale, k1.scale, eu);
|
|
const rot = k0.ease === 'step' ? k0.rot.slice() : quatToEuler(slerp(this._quat(k0), this._quat(k1), eu));
|
|
this.stage.setTransform(e.id, { pos, rot, scale });
|
|
}
|
|
|
|
_evalParams(e, t) {
|
|
const keys = e.tracks && e.tracks.params;
|
|
if (!keys || keys.length === 0) return;
|
|
const byKey = new Map();
|
|
for (const k of keys) { if (!byKey.has(k.key)) byKey.set(k.key, []); byKey.get(k.key).push(k); }
|
|
for (const [name, ks] of byKey) {
|
|
const br = bracket(ks, t);
|
|
const [k0, k1, u] = br;
|
|
let val;
|
|
if (k0 === k1) val = k0.value;
|
|
else {
|
|
const eu = ease(u, k0.ease);
|
|
val = isColor(k0.value)
|
|
? rgbToHex(lerpArr(hexToRgb(k0.value), hexToRgb(k1.value), eu))
|
|
: 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);
|
|
}
|
|
}
|
|
|
|
_evalClips(e, t) {
|
|
const blocks = e.tracks.clips;
|
|
if (!blocks || !blocks.length) return;
|
|
const mixer = this.stage.entityMixer(e.id);
|
|
if (!mixer) return; // SYNC1: real Stage returns null for non-characters (stub always had one)
|
|
for (let i = 0; i < blocks.length; i++) {
|
|
const b = blocks[i];
|
|
const rec = this._clipActions.get(b);
|
|
if (!rec) continue; // not preloaded yet → silent
|
|
const span = (b.out - b.in) || 0.0001;
|
|
const blockEnd = b.start + span * (b.loop || 1);
|
|
// pre-roll: a predecessor's fade pulls THIS block's playback start earlier
|
|
// by pred.fade so it advances during the crossfade (continuous local time).
|
|
const pred = blocks[i - 1];
|
|
const preroll = (pred && pred.fade > 0) ? pred.fade : 0;
|
|
const inStart = b.start - preroll;
|
|
let w = 0, local = b.in;
|
|
if (t >= inStart && t < blockEnd) {
|
|
local = b.in + ((t - inStart) % span); // continuous across the boundary
|
|
w = 1;
|
|
if (t < b.start) w = (t - inStart) / preroll; // fade-IN 0→1 over the pred's fade window
|
|
const succ = blocks[i + 1]; // fade-OUT 1→0 over own fade window
|
|
if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.min(w, (blockEnd - t) / b.fade);
|
|
}
|
|
rec.action.time = local;
|
|
rec.action.weight = Math.max(0, w);
|
|
rec.action.enabled = w > 0;
|
|
}
|
|
mixer.update(0); // set-time style — exact when scrubbing
|
|
}
|
|
|
|
// Which camera the EDIT is on at t: the last cut at or before it (null = the
|
|
// 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) { 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);
|
|
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) ----
|
|
_tracks(id) {
|
|
const e = this.scene.entities.find((x) => x.id === id);
|
|
if (!e) throw new Error(`no entity ${id}`);
|
|
e.tracks = e.tracks || {};
|
|
return e.tracks;
|
|
}
|
|
addKey(id, track, key) {
|
|
const tr = this._tracks(id);
|
|
const arr = (tr[track] = tr[track] || []);
|
|
// a key replaces any existing at the same t; named tracks (params/morphs)
|
|
// are keyed by (t, key name) since many names share the lane.
|
|
const named = track === 'params' || track === 'morphs';
|
|
const same = (k) => k.t === key.t && (!named || k.key === key.key);
|
|
const prevIdx = arr.findIndex(same);
|
|
const prev = prevIdx >= 0 ? arr[prevIdx] : null;
|
|
if (prevIdx >= 0) arr.splice(prevIdx, 1);
|
|
arr.push(key);
|
|
arr.sort((a, b) => a.t - b.t);
|
|
this._qcache.delete(key);
|
|
this.undoStack.push({ undo: () => { const i = arr.indexOf(key); if (i >= 0) arr.splice(i, 1); if (prev) { arr.push(prev); arr.sort((a, b) => a.t - b.t); } } });
|
|
return key;
|
|
}
|
|
moveKey(id, track, key, newT) {
|
|
const oldT = key.t; key.t = newT;
|
|
this._tracks(id)[track].sort((a, b) => a.t - b.t);
|
|
this.undoStack.push({ undo: () => { key.t = oldT; this._tracks(id)[track].sort((a, b) => a.t - b.t); } });
|
|
}
|
|
deleteKey(id, track, key) {
|
|
const arr = this._tracks(id)[track] || [];
|
|
const i = arr.indexOf(key);
|
|
if (i < 0) return;
|
|
arr.splice(i, 1);
|
|
this.undoStack.push({ undo: () => { arr.push(key); arr.sort((a, b) => a.t - b.t); } });
|
|
}
|
|
addClipBlock(id, block) {
|
|
const tr = this._tracks(id);
|
|
const arr = (tr.clips = tr.clips || []);
|
|
arr.push(block); arr.sort((a, b) => a.start - b.start);
|
|
this._clipActions.delete(block);
|
|
this.undoStack.push({ undo: () => { const i = arr.indexOf(block); if (i >= 0) arr.splice(i, 1); } });
|
|
return block;
|
|
}
|
|
moveClipBlock(id, block, newStart) {
|
|
const old = block.start; block.start = newStart;
|
|
this._tracks(id).clips.sort((a, b) => a.start - b.start);
|
|
this.undoStack.push({ undo: () => { block.start = old; this._tracks(id).clips.sort((a, b) => a.start - b.start); } });
|
|
}
|
|
trimClipBlock(id, block, { in: inV, out: outV } = {}) {
|
|
const oldIn = block.in, oldOut = block.out;
|
|
if (inV != null) block.in = inV;
|
|
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 };
|
|
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: () => { // 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;
|
|
this.scene.cameraCuts.splice(i, 1);
|
|
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
|
|
// pushed into ONE undo entry — a multi-key preset op reverts in one Ctrl+Z.
|
|
group(fn) {
|
|
const n = this.undoStack.length;
|
|
const r = fn();
|
|
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]}
|
|
// light {sun:{color,intensity}, ambient:{…}, bg, sunId, ambientId}
|
|
// mark {id, pos?, rot?, entityIds:[id]} )
|
|
// and the PLAN §5 contract shapes ({transform:{pos,rot}}, {params:{k:v}},
|
|
// walkTo). One op = ONE undo entry (group()).
|
|
applyDirect(d) {
|
|
if (d.op === 'move') return this.applyMove(d); // M11-B: its own group + window + toast
|
|
const t = this.time;
|
|
const key = (id, k) => this.addKey(id, 'params', { t, key: k[0], value: k[1], ease: 'inout' });
|
|
this.group(() => {
|
|
if (d.op === 'shot') {
|
|
const camId = d.camId ?? (d.entityIds && d.entityIds[0]);
|
|
const cur = this.stage.entityTransform(camId);
|
|
const tr = d.transform || d; // Lane A spreads pos/rot at top level
|
|
this.addKey(camId, 'transform', {
|
|
t, pos: tr.pos || cur.pos, rot: tr.rot || cur.rot,
|
|
scale: tr.scale ?? cur.scale ?? 1, ease: 'inout',
|
|
});
|
|
if (d.fov != null) key(camId, ['fov', d.fov]);
|
|
this.addCut(t, camId);
|
|
} else if (d.op === 'light') {
|
|
if (d.sunId != null && d.sun) { // Lane A applyLight shape
|
|
key(d.sunId, ['color', d.sun.color]);
|
|
key(d.sunId, ['intensity', d.sun.intensity]);
|
|
// sun boom pos/aim moved per preset — capture it so presets scrub
|
|
this.addKey(d.sunId, 'transform', { t, ...this.stage.entityTransform(d.sunId), ease: 'inout' });
|
|
if (d.ambientId != null && d.ambient) {
|
|
key(d.ambientId, ['color', d.ambient.color]);
|
|
key(d.ambientId, ['intensity', d.ambient.intensity]);
|
|
if (d.bg != null) key(d.ambientId, ['bg', d.bg]);
|
|
}
|
|
} else { // generic: entityId(s) + params map
|
|
const ids = d.entityIds || (d.entityId != null ? [d.entityId] : []);
|
|
for (const id of ids) for (const kv of Object.entries(d.params || {})) key(id, kv);
|
|
}
|
|
} else if (d.op === 'mark') {
|
|
const ids = d.entityIds || (d.entityId != null ? [d.entityId] : (d.id != null ? [d.id] : []));
|
|
for (const id of ids) {
|
|
const cur = this.stage.entityTransform(id);
|
|
const tr = d.transform || d; // Lane A spreads pos/rot at top level
|
|
const target = { pos: tr.pos || cur.pos, rot: tr.rot || cur.rot, scale: tr.scale ?? cur.scale ?? 1 };
|
|
if (d.walkTo) { // walk: from prevPos (or current) → mark over 2s
|
|
const from = d.prevPos ? { ...cur, pos: d.prevPos } : cur;
|
|
this.addKey(id, 'transform', { t, ...from, ease: 'inout' });
|
|
this.addKey(id, 'transform', { t: t + 2, ...target, ease: 'inout' });
|
|
} else {
|
|
this.addKey(id, 'transform', { t, ...target, ease: 'inout' });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
this.seek(t); // re-evaluate: cut/keys take effect now
|
|
}
|
|
|
|
// ---- M11-B: CAMERA MOVES (Lane A's 🎬 move — contract: logs/laneA.md s7) ----
|
|
// `from`/`to` are complete camera states {pos, rot, fov}; Lane A has ALREADY
|
|
// put the live camera on `from`, so until this lands the END exists only in the
|
|
// event and nothing animates. Landed as ordinary keys, so a move is scrubbable,
|
|
// draggable and undoable like anything else on the timeline:
|
|
// • transform key at t0 (from) and at t0+dur (to) — `ease` on the FIRST of
|
|
// each pair (a segment reads its ease off its left key);
|
|
// • an `fov` param key at BOTH times, ALWAYS, even on a truck where it never
|
|
// changes: fov is the ONLY thing a zoom moves, and an unkeyed fov gets
|
|
// dragged around by whatever fov keys sit either side of the move.
|
|
// Existing transform/fov keys INSIDE [t0, t0+dur] are replaced (and counted) —
|
|
// a second move over the same span would otherwise interleave with the first
|
|
// and read as jitter. Same instinct as auto-cut replacing cuts on its beats.
|
|
// Everything inside ONE group() ⇒ ONE undo.
|
|
// NO CUT is placed: a move often continues the shot that is already running, so
|
|
// auto-cutting would silently change the edit. Opt in per-op with `cut: true`.
|
|
// ponytail: two keys, so position LERPS — an orbit travels the chord, not the
|
|
// arc (measured: 1.611m radius, 3.8cm short at the midpoint of Lane A's ±25°
|
|
// default — invisible; 47cm on a 90° orbit, which would visibly cut the
|
|
// corner). Intermediate keys only if someone actually asks for wide orbits.
|
|
applyMove(d) {
|
|
const camId = d.camId ?? (d.entityIds && d.entityIds[0]);
|
|
if (camId == null) throw new Error('move: no camera id in the payload');
|
|
const from = d.from, to = d.to;
|
|
if (!from || !to || !from.pos || !to.pos) throw new Error('move: needs from/to camera states');
|
|
const easeKind = d.ease === 'linear' ? 'linear' : 'inout'; // EASES = inout | linear
|
|
const { t0, dur, snapped } = this.snapMove(d.t0 == null ? this.time : +d.t0, +d.dur || 0);
|
|
if (!(dur > 0)) throw new Error('move: needs a duration > 0');
|
|
const t1 = t0 + dur;
|
|
const cur = this.stage.entityTransform(camId) || {};
|
|
const st = (s) => ({ pos: s.pos.slice(), rot: (s.rot || cur.rot || [0, 0, 0]).slice(), scale: cur.scale ?? 1 });
|
|
const fov0 = from.fov ?? to.fov ?? this._paramNow(camId, 'fov');
|
|
const fov1 = to.fov ?? fov0;
|
|
const replaced = this.group(() => {
|
|
const gone = this._clearMoveWindow(camId, t0, t1);
|
|
this.addKey(camId, 'transform', { t: t0, ...st(from), ease: easeKind });
|
|
this.addKey(camId, 'transform', { t: t1, ...st(to), ease: easeKind });
|
|
if (fov0 != null) {
|
|
this.addKey(camId, 'params', { t: t0, key: 'fov', value: fov0, ease: easeKind });
|
|
this.addKey(camId, 'params', { t: t1, key: 'fov', value: fov1, ease: easeKind });
|
|
}
|
|
if (d.cut) this.addCut(t0, camId); // opt-in only — never the default
|
|
return gone;
|
|
});
|
|
this.seek(t0); // playhead + viewport on the move's first frame
|
|
const e = this.scene.entities.find((x) => x.id === camId);
|
|
// Disclosure, not a decision: since no cut is placed, a move on a camera the
|
|
// edit never cuts to renders as nothing. Say so instead of leaving the user
|
|
// to wonder why their push-in isn't in the mp4.
|
|
const live = this.cutCameraAt(t0);
|
|
this._say(`🎬 ${String(d.move || 'move').replace(/_/g, ' ')} · ${dur.toFixed(1)}s on ${(e && e.label) || camId}`
|
|
+ ` · 2 transform + ${fov0 != null ? 2 : 0} fov keys`
|
|
+ (replaced.length ? ` · replaced ${replaced.length}` : '')
|
|
+ (snapped ? ` · snapped to ${snapped}` : '') + (d.cut ? ' · cut' : '')
|
|
+ (this.cameraCuts.length && live !== camId ? ` · note: ${live || 'no camera'} is live here — no cut selects this one` : ''));
|
|
return { camId, t0, dur, ease: easeKind, keys: 2 + (fov0 != null ? 2 : 0), replaced: replaced.length, snapped, live };
|
|
}
|
|
|
|
// The keys a move takes ownership of: transform keys and `fov` param keys
|
|
// inside its window, endpoints included (a repeat of the same move must land
|
|
// on its own keys, not beside them). Returned so the caller can report the
|
|
// count — a silent replacement reads as a bug the second time you press it.
|
|
_clearMoveWindow(id, t0, t1, eps = 1e-6) {
|
|
const tr = this._tracks(id);
|
|
const inWin = (k) => k.t >= t0 - eps && k.t <= t1 + eps;
|
|
const gone = [
|
|
...(tr.transform || []).filter(inWin).map((k) => ['transform', k]),
|
|
...(tr.params || []).filter((k) => k.key === 'fov' && inWin(k)).map((k) => ['params', k]),
|
|
];
|
|
for (const [track, k] of gone) this.deleteKey(id, track, k);
|
|
return gone.map(([, k]) => k);
|
|
}
|
|
|
|
_paramNow(id, key) { // current value of a keyable param (scene, then stage)
|
|
const e = this.scene.entities.find((x) => x.id === id)
|
|
|| (this.stage.getEntity ? this.stage.getEntity(id) : null);
|
|
return e && e.params ? e.params[key] : undefined;
|
|
}
|
|
|
|
// Put a move on the music. When the UI's snap is set to beat/bar and a grid is
|
|
// loaded, `t0` snaps exactly like every other drag; on `bar` the END is snapped
|
|
// to a downbeat too, so a move starts AND stops on the music — the first thing
|
|
// that makes the beat grid apply to camera work. Guards: the end never leaves
|
|
// the scene (walk back whole bars, re-snapping each step, like
|
|
// alignShotsToGrid) and the duration is never <= 0 — if snapping can't satisfy
|
|
// both, the raw values are returned untouched.
|
|
snapMove(t0, dur) {
|
|
const raw = { t0, dur, snapped: null };
|
|
const kind = this.snap && this.snap.on ? this.snap.step : null;
|
|
if ((kind !== 'beat' && kind !== 'bar') || !this.scene.beatGrid) return raw;
|
|
const a = this.snapToGrid(t0, kind);
|
|
if (a == null || a >= this.duration) return raw;
|
|
let d = Math.min(dur, this.duration - a); // Lane A's clamp, re-applied at the snapped start
|
|
if (kind === 'bar') {
|
|
const bar = this._beatPeriod('bar');
|
|
let b = this.snapToGrid(a + d, 'bar');
|
|
for (let g = 0; b != null && bar > 0 && b > this.duration + 1e-9 && g < 64; g++) b = this.snapToGrid(b - bar, 'bar');
|
|
if (b != null && b > a + 1e-6 && b <= this.duration + 1e-9) d = b - a;
|
|
}
|
|
return d > 0 ? { t0: a, dur: d, snapped: kind } : raw;
|
|
}
|
|
|
|
// ---- audio track (M4-B). Clips are {path, start, gain}; duration is a
|
|
// client-side draw concern (decode + cache in tlui). Server muxes from
|
|
// scene.audio at render, so step()/evaluate never touch audio. ----
|
|
addAudio(clip) {
|
|
this.scene.audio = this.scene.audio || [];
|
|
this.scene.audio.push(clip);
|
|
this.undoStack.push({ undo: () => { const i = this.scene.audio.indexOf(clip); if (i >= 0) this.scene.audio.splice(i, 1); } });
|
|
return clip;
|
|
}
|
|
moveAudio(clip, newStart) {
|
|
const old = clip.start; clip.start = Math.max(0, newStart);
|
|
this.undoStack.push({ undo: () => { clip.start = old; } });
|
|
}
|
|
setAudioGain(clip, gain) {
|
|
const old = clip.gain; clip.gain = gain;
|
|
this.undoStack.push({ undo: () => { clip.gain = old; } });
|
|
}
|
|
removeAudio(clip) {
|
|
const i = (this.scene.audio || []).indexOf(clip);
|
|
if (i < 0) return;
|
|
this.scene.audio.splice(i, 1);
|
|
this.undoStack.push({ undo: () => { this.scene.audio.splice(i, 0, clip); } });
|
|
}
|
|
// Trim within the source file, like clip blocks: {in, out} seconds. Absent =
|
|
// untrimmed (schema stays {path,start,gain} for untouched clips). Server-side
|
|
// mux honoring in/out is a Lane C REQUEST (logged); draft Web Audio playback
|
|
// honors it client-side already.
|
|
trimAudio(clip, { in: inV, out: outV } = {}) {
|
|
const oldIn = clip.in, oldOut = clip.out;
|
|
if (inV != null) clip.in = Math.max(0, inV);
|
|
if (outV != null) clip.out = Math.max((clip.in || 0) + 0.05, outV);
|
|
this.undoStack.push({ undo: () => {
|
|
if (oldIn === undefined) delete clip.in; else clip.in = oldIn;
|
|
if (oldOut === undefined) delete clip.out; else clip.out = oldOut;
|
|
} });
|
|
}
|
|
|
|
// Head trim: dragging an audio block's LEFT edge eats into the source rather
|
|
// than moving the block — `in` grows by exactly what `start` does, so what you
|
|
// still hear stays put on the timeline. The pair is ONE undo entry.
|
|
trimAudioIn(clip, newStart, srcDur = null) {
|
|
const inT = clip.in || 0;
|
|
const outT = clip.out ?? srcDur;
|
|
const lo = -Math.min(inT, clip.start); // can't rewind past the file head or t=0
|
|
const hi = outT != null ? Math.max(0, outT - inT - 0.05) : Infinity; // always leave a sliver
|
|
const d = Math.max(lo, Math.min(hi, newStart - clip.start));
|
|
if (!d) return;
|
|
this.group(() => { this.trimAudio(clip, { in: inT + d }); this.moveAudio(clip, clip.start + d); });
|
|
}
|
|
|
|
// ---- M9-B1: BEAT GRID (Lane C's `GET beats?path=`) -------------------------
|
|
// Stored ON THE SCENE (`scene.beatGrid`) so it survives save/load — the grid is
|
|
// 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'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.
|
|
//
|
|
// {path, bpm, confidence, meter, period, offset, beats[], downbeats[]}
|
|
//
|
|
// `beats`/`downbeats` stay in FILE seconds exactly as Lane C returns them;
|
|
// `offset` is the timeline second the file's t=0 sits at. The effective offset
|
|
// is re-derived from the audio clip with the same path whenever one is still
|
|
// on the timeline, so dragging the audio block drags the grid with it.
|
|
setBeatGrid(grid) {
|
|
if (!grid) { delete this.scene.beatGrid; return null; }
|
|
this.scene.beatGrid = grid;
|
|
return grid;
|
|
}
|
|
clearBeatGrid() { delete this.scene.beatGrid; }
|
|
|
|
// All four delegate to the pure module helpers at the bottom of this file —
|
|
// the FILM panel aligns ANOTHER scene's shot list to ITS grid, with no
|
|
// Timeline instance in hand, and must get identical arithmetic.
|
|
beatOffset() { return beatOffsetOf(this.scene); }
|
|
_beatPeriod(kind) { return beatPeriodOf(this.scene.beatGrid, kind); }
|
|
|
|
// Grid positions in TIMELINE seconds, extended at the constant tempo across the
|
|
// whole scene so the drawn lines and snapToGrid() agree everywhere — not only
|
|
// where the audio file happens to have samples.
|
|
gridTimes(kind = 'beat') {
|
|
return gridTimesOf(this.scene.beatGrid, kind, this.beatOffset(), this.duration);
|
|
}
|
|
|
|
// Nearest grid line to t, in timeline seconds. null = no grid (caller falls
|
|
// back to its fixed increment). Outside the analysed range the constant tempo
|
|
// is extrapolated, same as gridTimes.
|
|
snapToGrid(t, kind = 'beat') {
|
|
return snapToGridWith(this.scene.beatGrid, t, kind, this.beatOffset());
|
|
}
|
|
|
|
// ---- M10-B1: AUTO-CUT — a beat grid + 2 cameras → cuts on the downbeats ----
|
|
// Deliberately dumb and predictable: every Nth downbeat in range gets a cut,
|
|
// cameras cycle in SCENE order (2 cameras = strict A/B, which is what a DJ
|
|
// edit looks like), and the result is ordinary cameraCuts the user can then
|
|
// hand-edit. No randomness, no energy analysis, no "smart" variation.
|
|
|
|
// Cameras in scene order, plus any live-on-stage camera the model hasn't
|
|
// mirrored yet (same merge tlui uses to build its rows).
|
|
cameraIds() {
|
|
const ids = this.scene.entities.filter((e) => e.kind === 'camera').map((e) => e.id);
|
|
if (this.stage && this.stage.entities) {
|
|
for (const se of this.stage.entities()) if (se.kind === 'camera' && !ids.includes(se.id)) ids.push(se.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// Pure: what autoCut WOULD do. The popover shows `cuts.length` before the user
|
|
// commits, and Apply runs the same plan — so the count on screen is the count
|
|
// you get. `{ok:false, error}` for every refusal (no grid / <2 cameras / empty
|
|
// range); never a silent no-op.
|
|
autoCutPlan(opts = {}) {
|
|
const g = this.scene.beatGrid;
|
|
if (!g) return { ok: false, error: 'no beat grid — press ♪ on an audio row to detect one first' };
|
|
const cadence = Math.max(1, Math.round(+opts.cadence || 2));
|
|
const all = this.cameraIds();
|
|
const cams = opts.cameras && opts.cameras.length ? all.filter((id) => opts.cameras.includes(id)) : all;
|
|
if (cams.length < 2) {
|
|
return { ok: false, error: `auto-cut needs at least 2 cameras — ${cams.length === 1 ? 'only 1 is' : 'none are'} selected in this scene` };
|
|
}
|
|
const from = Math.max(0, opts.from == null ? 0 : +opts.from);
|
|
const to = Math.min(this.duration, opts.to == null ? this.duration : +opts.to);
|
|
const bars = this.gridTimes('bar').filter((t) => t >= from - 1e-9 && t <= to + 1e-9);
|
|
if (!bars.length) return { ok: false, error: `no downbeats between ${from.toFixed(2)}s and ${to.toFixed(2)}s` };
|
|
const times = bars.filter((_, i) => i % cadence === 0);
|
|
// Don't open with the camera that is already live going into the range —
|
|
// otherwise "from the playhead" can land the same camera twice in a row.
|
|
const eps = 0.5 / (this.fps || 30);
|
|
const before = this.cameraCuts.filter((c) => c.t < times[0] - eps);
|
|
const prior = before.length ? before[before.length - 1] : null;
|
|
const phase = prior && prior.camera === cams[0] ? 1 : 0;
|
|
const cuts = times.map((t, i) => ({ t, camera: cams[(i + phase) % cams.length] }));
|
|
// An existing cut ON a placed position is REPLACED (half a frame either way,
|
|
// so a hand-placed cut near the downbeat is absorbed rather than duplicated
|
|
// into a flash frame). Cuts elsewhere in the range are left alone.
|
|
const replaced = this.cameraCuts.filter((c) => times.some((t) => Math.abs(c.t - t) <= eps));
|
|
// Hand-placed cuts elsewhere in the range are NOT touched — deleting someone's
|
|
// edit is over-reach — with ONE exception (SYNC13, orchestrator): a kept cut
|
|
// that selects the camera already live at that instant is a literal no-op. It
|
|
// cuts from cam2 to cam2, nothing happens on screen, and it still rides into
|
|
// the saved scene looking like an edit. Those are dropped; the rest are
|
|
// reported as `kept` so the popover can own the interleave out loud.
|
|
// The "already live" camera is the EFFECTIVE one after the auto cuts are
|
|
// merged in, so walk the merged list in time order rather than comparing
|
|
// against the previous array element.
|
|
const survivors = this.cameraCuts.filter((c) => !replaced.includes(c));
|
|
const merged = [...survivors.map((c) => ({ c, auto: false })), ...cuts.map((c) => ({ c, auto: true }))]
|
|
.sort((a, b) => a.c.t - b.c.t);
|
|
const kept = [], dropped = [];
|
|
let eff = null;
|
|
for (const m of merged) {
|
|
if (!m.auto && m.c.t >= times[0] && m.c.t <= times[times.length - 1]) {
|
|
if (m.c.camera === eff) { dropped.push(m.c); continue; } // cuts from X to X = nothing
|
|
kept.push(m.c);
|
|
}
|
|
eff = m.c.camera;
|
|
}
|
|
return {
|
|
ok: true, cuts, replaced, kept, dropped, cameras: cams, cadence,
|
|
barPeriod: this._beatPeriod('bar'), confidence: g.confidence ?? 0, bpm: g.bpm,
|
|
from, to,
|
|
};
|
|
}
|
|
|
|
// Apply the plan as ONE undo entry. Refuses loudly (throws) rather than
|
|
// half-applying — the caller already has autoCutPlan() to check first.
|
|
autoCut(opts = {}) {
|
|
const plan = this.autoCutPlan(opts);
|
|
if (!plan.ok) throw new Error(plan.error);
|
|
this.group(() => {
|
|
for (const c of plan.replaced) this.removeCut(c);
|
|
for (const c of plan.dropped) this.removeCut(c); // no-op cuts (cam X → cam X)
|
|
for (const c of plan.cuts) this.addCut(c.t, c.camera);
|
|
});
|
|
this.seek(this.time); // the new cut under the playhead goes live
|
|
return plan;
|
|
}
|
|
|
|
// Rhubarb lip-sync JSON → morph keys. { mouthCues: [{start, end, value}] }.
|
|
// ponytail: per-cue ramp (shape 1 at cue start → 0 at cue end); hold/crossfade
|
|
// tuning deferred until it looks wrong on a real blendshape character.
|
|
importRhubarb(id, rhubarbJson, t0 = 0) {
|
|
const cues = (rhubarbJson && rhubarbJson.mouthCues) || [];
|
|
this.group(() => { // one undo reverts the whole import
|
|
for (const c of cues) {
|
|
if (!c.value) continue;
|
|
this.addKey(id, 'morphs', { t: t0 + c.start, key: c.value, value: 1, ease: 'linear' });
|
|
this.addKey(id, 'morphs', { t: t0 + c.end, key: c.value, value: 0, ease: 'linear' });
|
|
}
|
|
});
|
|
return (this._tracks(id).morphs || []).length;
|
|
}
|
|
}
|
|
|
|
// ---- pure beat-grid helpers ------------------------------------------------
|
|
// The Timeline methods above are thin wrappers over these. They exist as free
|
|
// functions because the FILM panel (M10-B2) aligns the shots of scenes that are
|
|
// NOT loaded — it holds a grid and a shot list, not a Timeline — and it must get
|
|
// bit-identical arithmetic to what the user saw while cutting.
|
|
|
|
// Where the grid's file t=0 sits on that scene's timeline: the audio clip that
|
|
// carries the grid wins (drag the audio, drag the grid), else the stored offset.
|
|
export function beatOffsetOf(scene) {
|
|
const g = scene && scene.beatGrid;
|
|
if (!g) return 0;
|
|
const clip = ((scene && scene.audio) || []).find((c) => c.path === g.path);
|
|
return clip ? clip.start - (clip.in || 0) : (g.offset || 0);
|
|
}
|
|
export function beatPeriodOf(grid, kind = 'beat') {
|
|
if (!grid) return 0;
|
|
const per = grid.period || (grid.bpm ? 60 / grid.bpm : 0);
|
|
return kind === 'bar' ? per * (grid.meter || 4) : per;
|
|
}
|
|
export function gridTimesOf(grid, kind = 'beat', off = 0, span = 0) {
|
|
if (!grid) return [];
|
|
const src = kind === 'bar' ? (grid.downbeats || []) : (grid.beats || []);
|
|
if (!src.length) return [];
|
|
const per = beatPeriodOf(grid, kind);
|
|
const inFile = src.map((b) => b + off);
|
|
if (!(per > 0.05)) return inFile.filter((t) => t >= 0); // nonsense tempo → no extrapolation
|
|
const before = [];
|
|
for (let x = inFile[0] - per; x >= 0; x -= per) before.push(x);
|
|
const after = [];
|
|
for (let x = inFile[inFile.length - 1] + per; x <= span; x += per) after.push(x);
|
|
return [...before.reverse(), ...inFile.filter((t) => t >= 0), ...after];
|
|
}
|
|
export function snapToGridWith(grid, t, kind = 'beat', off = 0) {
|
|
if (!grid) return null;
|
|
const src = kind === 'bar' ? grid.downbeats : grid.beats;
|
|
if (!src || !src.length) return null;
|
|
const per = beatPeriodOf(grid, kind);
|
|
const x = t - off;
|
|
const last = src[src.length - 1];
|
|
let r;
|
|
if (per > 0 && x <= src[0]) r = src[0] + Math.round((x - src[0]) / per) * per;
|
|
else if (per > 0 && x >= last) r = last + Math.round((x - last) / per) * per;
|
|
else {
|
|
let lo = 0, hi = src.length - 1;
|
|
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (src[m] <= x) lo = m; else hi = m; }
|
|
r = (x - src[lo] <= src[hi] - x) ? src[lo] : src[hi];
|
|
}
|
|
r += off;
|
|
if (r < 0 && per > 0) r += per; // never snap off the front of the timeline
|
|
return Math.max(0, r);
|
|
}
|
|
|
|
// M10-B2: snap a FILM shot list's in/out to downbeats so the shot boundaries
|
|
// land musically. Order is never touched. A shot that would collapse (in >= out
|
|
// after snapping, e.g. a shot shorter than a bar) is left EXACTLY as it was and
|
|
// reported — silently mangling someone's edit is worse than not helping.
|
|
// `durations[i]` (a scene's length, 0/absent = unknown) keeps `out` inside its
|
|
// scene: an out past the end walks back whole bars until it fits.
|
|
export function alignShotsToGrid(shots, grid, off = 0, durations = []) {
|
|
const bar = beatPeriodOf(grid, 'bar');
|
|
const r6 = (x) => Math.round(x * 1e6) / 1e6; // kill float noise in the panel's number fields
|
|
const out = [];
|
|
const skipped = [];
|
|
(shots || []).forEach((sh, i) => {
|
|
const a0 = +sh.in || 0, b0 = +sh.out || 0;
|
|
let a = snapToGridWith(grid, a0, 'bar', off);
|
|
let b = snapToGridWith(grid, b0, 'bar', off);
|
|
const lim = +durations[i] || 0;
|
|
if (a == null || b == null) { out.push({ ...sh }); skipped.push({ index: i, scene: sh.scene, why: 'no downbeats' }); return; }
|
|
// Walk back a bar at a time and RE-SNAP each step: `b - bar` is a bar-period
|
|
// away from a grid line, not on one (Lane C rounds beats to 4dp, the period
|
|
// to 6dp), and "lands on a downbeat" has to mean the real one.
|
|
for (let guard = 0; lim && bar > 0 && b > lim + 1e-9 && guard < 64; guard++) b = snapToGridWith(grid, b - bar, 'bar', off);
|
|
if (!(b > a + 1e-9)) { out.push({ ...sh }); skipped.push({ index: i, scene: sh.scene, why: 'would collapse' }); return; }
|
|
out.push({ ...sh, in: r6(a), out: r6(b) });
|
|
});
|
|
return { shots: out, skipped };
|
|
}
|
|
|
|
// Lane C's /beats response → the scene-stored grid (M9-B1). Pure, so the shape
|
|
// contract is testable headless against a real captured payload; the fetch + the
|
|
// per-path cache live in tlui, like every other server call in this lane.
|
|
// `offset` = the timeline second where the file's t=0 sits (audio clip
|
|
// `start - (in||0)`, per Lane C's log: "beat + audioClip.start - (audioClip.in||0)").
|
|
export function beatGridFrom(res, offset = 0) {
|
|
return {
|
|
path: res.path,
|
|
bpm: res.bpm,
|
|
confidence: res.confidence ?? 0,
|
|
meter: res.meter || 4,
|
|
period: res.period || (res.bpm ? 60 / res.bpm : 0),
|
|
offset,
|
|
beats: res.beats || [],
|
|
downbeats: res.downbeats || [],
|
|
};
|
|
}
|
|
|
|
export default Timeline;
|