@amg555/frameforge-mcp 2.3.1 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +2 -4
- package/CHANGELOG.md +20 -1
- package/CLAUDE.md +154 -18
- package/README.md +16 -12
- package/SECURITY.md +1 -3
- package/install.sh +1 -1
- package/package.json +7 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
- package/scripts/db-migrate.js +112 -0
- package/scripts/deep-validate.js +326 -0
- package/scripts/generate-initial-assets.js +342 -0
- package/scripts/html-renderer/new-project.mjs +408 -0
- package/scripts/html-renderer/transcribe.py +294 -0
- package/src/asset-library/README.md +2 -2
- package/src/asset-library/asset-library.js +1 -1
- package/src/cli/doctor.js +31 -1
- package/src/cli/make.js +52 -71
- package/src/cli/setup-keys.js +24 -19
- package/src/fact-checker/review-gate.js +5 -1
- package/src/harness/bin/harness.mjs +324 -0
- package/src/harness/lib/browser.mjs +137 -0
- package/src/harness/lib/capture.mjs +161 -0
- package/src/harness/lib/encode.mjs +89 -0
- package/src/harness/lib/jobs.mjs +135 -0
- package/src/harness/lib/pixels.mjs +137 -0
- package/src/harness/lib/sheets.mjs +164 -0
- package/src/harness/lib/verify-purity.mjs +119 -0
- package/src/harness/test/fixtures/broken-scene.html +27 -0
- package/src/harness/test/fixtures/reference-scene.html +23 -0
- package/src/harness/test/fixtures/sample-script.json +18 -0
- package/src/mcp-server/server.js +318 -66
- package/src/pipeline/external-adapter.js +55 -15
- package/src/pipeline/providers.js +46 -0
- package/src/pipeline/telemetry.js +1 -0
- package/src/utils/log-redaction.js +5 -3
- package/src/video-renderer/renderer.js +12 -4
- package/src/worker/server-render-free.js +2 -3
- package/src/workflows/assets.js +20 -43
- package/src/workflows/ingest.js +5 -5
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.js — FrameForge HTML Compositor Runtime
|
|
3
|
+
*
|
|
4
|
+
* Every visual style is a pure function of t (seconds).
|
|
5
|
+
* No CSS transitions, no setTimeout, no mutable frame state.
|
|
6
|
+
*
|
|
7
|
+
* IRON RULE — the determinism contract:
|
|
8
|
+
* ✅ Use hash(i) for anything that must be reproducible across frames.
|
|
9
|
+
* ✅ Read time from t / lt / dur — never from performance.now() or Date.now().
|
|
10
|
+
* ❌ NEVER call Math.random() — breaks parallel frame workers.
|
|
11
|
+
* ❌ NEVER use setTimeout / setInterval — Playwright virtualises the clock.
|
|
12
|
+
* ❌ NEVER integrate physics per-tick — precompute or use closed-form math.
|
|
13
|
+
* ❌ NEVER store frame state in module-level mutable counters inside a layer fn.
|
|
14
|
+
*
|
|
15
|
+
* Usage in edit.js:
|
|
16
|
+
* import { reel, layer, text, wallCard, at, stagger, captions, faceAt, hash } from './reel/engine.js';
|
|
17
|
+
* reel({ width: 1920, height: 1080, fps: 30, duration: 65 });
|
|
18
|
+
* // fn(t, lt, dur): t = video time, lt = time inside this shot, dur = shot length
|
|
19
|
+
* layer('title', { start: 0, end: 5 }).on((t, lt, dur) => ({
|
|
20
|
+
* opacity: lt < 0.4 ? lt / 0.4 : lt > dur - 0.4 ? (dur - lt) / 0.4 : 1,
|
|
21
|
+
* }));
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ── Global reel state ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export const REEL = {
|
|
27
|
+
width: 1920,
|
|
28
|
+
height: 1080,
|
|
29
|
+
fps: 30,
|
|
30
|
+
duration: 60,
|
|
31
|
+
title: '',
|
|
32
|
+
captionY: null, // override caption baseline (px from top)
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Configure the compositor. Call once at the top of edit.js. */
|
|
36
|
+
export function reel({ width = 1920, height = 1080, fps = 30, duration = 60, title = '' } = {}) {
|
|
37
|
+
REEL.width = width;
|
|
38
|
+
REEL.height = height;
|
|
39
|
+
REEL.fps = fps;
|
|
40
|
+
REEL.duration = duration;
|
|
41
|
+
REEL.title = title;
|
|
42
|
+
// Expose for render.mjs virtual-clock handshake
|
|
43
|
+
window.DURATION = duration;
|
|
44
|
+
window.FPS = fps;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Layer registry ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const _layers = []; // { name, el, compute }
|
|
50
|
+
const _atElements = []; // { t, el, duration }
|
|
51
|
+
const _captionPath = null; // set by captions()
|
|
52
|
+
let _captionData = null; // loaded JSON
|
|
53
|
+
|
|
54
|
+
class Layer {
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} name
|
|
57
|
+
* @param {{ start?: number, end?: number }} opts
|
|
58
|
+
* start time (s) when this shot begins (default 0 = whole film)
|
|
59
|
+
* end time (s) when this shot ends (default REEL.duration)
|
|
60
|
+
*/
|
|
61
|
+
constructor(name, opts = {}) {
|
|
62
|
+
this.name = name;
|
|
63
|
+
this.start = opts.start ?? 0;
|
|
64
|
+
this.end = opts.end ?? null; // null = REEL.duration (resolved at tick time)
|
|
65
|
+
this.el = document.createElement('div');
|
|
66
|
+
this.el.dataset.layer = name;
|
|
67
|
+
document.getElementById('stage').appendChild(this.el);
|
|
68
|
+
_layers.push(this);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register per-frame compute function.
|
|
73
|
+
* fn(t, lt, dur) → style object
|
|
74
|
+
* t = absolute video time (seconds)
|
|
75
|
+
* lt = local time inside this shot (t - start)
|
|
76
|
+
* dur = shot duration (end - start)
|
|
77
|
+
*/
|
|
78
|
+
on(fn) {
|
|
79
|
+
this._compute = fn;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_tick(t) {
|
|
84
|
+
if (!this._compute) return;
|
|
85
|
+
const end = this.end ?? REEL.duration;
|
|
86
|
+
const dur = end - this.start;
|
|
87
|
+
const lt = t - this.start;
|
|
88
|
+
const styles = this._compute(t, lt, dur);
|
|
89
|
+
if (!styles) return;
|
|
90
|
+
Object.assign(this.el.style, _stylesToCSS(styles));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Declare a named layer.
|
|
96
|
+
* @param {string} name
|
|
97
|
+
* @param {{ start?: number, end?: number }} opts Shot window (optional)
|
|
98
|
+
* @returns {Layer}
|
|
99
|
+
*/
|
|
100
|
+
export function layer(name, opts = {}) {
|
|
101
|
+
const existing = _layers.find(l => l.name === name);
|
|
102
|
+
if (existing) return existing;
|
|
103
|
+
return new Layer(name, opts);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── at() / stagger() — timed element placement ───────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Place an element at time t. The element is removed after `duration` seconds.
|
|
110
|
+
* @param {number} t Start time in seconds
|
|
111
|
+
* @param {HTMLElement} el Element to mount
|
|
112
|
+
*/
|
|
113
|
+
export function at(t, el) {
|
|
114
|
+
if (!el || !(el instanceof HTMLElement)) return;
|
|
115
|
+
document.getElementById('stage').appendChild(el);
|
|
116
|
+
el.style.display = 'none';
|
|
117
|
+
_atElements.push({ t, el, duration: el._duration ?? Infinity });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Place elements with a stagger offset.
|
|
122
|
+
* @param {number} t Start time for first element
|
|
123
|
+
* @param {number} dt Delay between each element (seconds)
|
|
124
|
+
* @param {HTMLElement[]} els Elements to mount
|
|
125
|
+
* @returns {{ t: number, element: HTMLElement }[]} timing entries (useful in tests)
|
|
126
|
+
*/
|
|
127
|
+
export function stagger(t, dt, els) {
|
|
128
|
+
const result = [];
|
|
129
|
+
els.forEach((el, i) => {
|
|
130
|
+
const ti = t + i * dt;
|
|
131
|
+
result.push({ t: ti, element: el });
|
|
132
|
+
if (el instanceof HTMLElement) at(ti, el);
|
|
133
|
+
});
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── seek(t) — the clock interface ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Advance the compositor to time t (seconds).
|
|
141
|
+
* Called by render.mjs for every frame.
|
|
142
|
+
*/
|
|
143
|
+
export function seek(t) {
|
|
144
|
+
// Tick all layers
|
|
145
|
+
for (const l of _layers) l._tick(t);
|
|
146
|
+
|
|
147
|
+
// Show/hide timed elements
|
|
148
|
+
for (const entry of _atElements) {
|
|
149
|
+
const visible = t >= entry.t && t < entry.t + entry.duration;
|
|
150
|
+
entry.el.style.display = visible ? '' : 'none';
|
|
151
|
+
if (visible && entry.el._seekFn) entry.el._seekFn(t - entry.t);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Live captions
|
|
155
|
+
if (_captionData) _tickCaptions(t);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Expose globally for the virtual-clock renderer.
|
|
159
|
+
// Also set window.__seek and window.renderFrame so that the virtual-clock
|
|
160
|
+
// shim (injected as an initScript before the module loads) delegates to the
|
|
161
|
+
// engine's seek() regardless of which alias the render script calls.
|
|
162
|
+
window.seek = seek;
|
|
163
|
+
window.__seek = (t) => seek(t); // render.mjs calls __seek(t)
|
|
164
|
+
window.renderFrame = (t) => seek(t); // virtual-clock.js's seek() calls renderFrame
|
|
165
|
+
|
|
166
|
+
// ── Math utilities ────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* hash(i) — Deterministic pseudo-random number in [0, 1) for index i.
|
|
170
|
+
*
|
|
171
|
+
* Use this INSTEAD OF Math.random() for anything that must stay reproducible
|
|
172
|
+
* across parallel frame workers (star positions, brush seeds, particle jitter).
|
|
173
|
+
*
|
|
174
|
+
* For "boil" wobble (hand-drawn animation feel), reseed 12 times per second:
|
|
175
|
+
* const wobble = hash(i + Math.floor(t * 12));
|
|
176
|
+
*
|
|
177
|
+
* Determinism law: seed all stochastic operations.
|
|
178
|
+
*
|
|
179
|
+
* @param {number} i Integer seed (particle index, frame seed, etc.)
|
|
180
|
+
* @returns {number} Deterministic float in [0, 1)
|
|
181
|
+
*/
|
|
182
|
+
export function hash(i) {
|
|
183
|
+
let x = Math.sin(i + 1) * 43758.5453123;
|
|
184
|
+
return x - Math.floor(x);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* hash2(i, j) — Two-dimensional hash for (col, row) grid seeds, etc.
|
|
189
|
+
* @returns {number} Deterministic float in [0, 1)
|
|
190
|
+
*/
|
|
191
|
+
export function hash2(i, j) {
|
|
192
|
+
return hash(i * 1000 + j);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Linear interpolation from a to b over [t0, t1], clamped at boundaries. */
|
|
196
|
+
export function lerp(t, a, b, t0 = 0, t1 = 1) {
|
|
197
|
+
if (t <= t0) return a;
|
|
198
|
+
if (t >= t1) return b;
|
|
199
|
+
const p = (t - t0) / (t1 - t0);
|
|
200
|
+
return a + (b - a) * p;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Ease-in (quadratic). Input 0–1, output 0–1. */
|
|
204
|
+
export function easeIn(t) {
|
|
205
|
+
const p = Math.max(0, Math.min(1, t));
|
|
206
|
+
return p * p;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Ease-out (quadratic). Input 0–1, output 0–1. */
|
|
210
|
+
export function easeOut(t) {
|
|
211
|
+
const p = Math.max(0, Math.min(1, t));
|
|
212
|
+
return 1 - (1 - p) * (1 - p);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Ease-in-out (quadratic). Input 0–1, output 0–1. */
|
|
216
|
+
export function easeInOut(t) {
|
|
217
|
+
const p = Math.max(0, Math.min(1, t));
|
|
218
|
+
return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Spring physics. Returns value 0→1 (may briefly exceed 1 when underdamped).
|
|
223
|
+
* @param {number} t Current time in seconds
|
|
224
|
+
* @param {{ tension, friction, delay }} opts
|
|
225
|
+
*/
|
|
226
|
+
export function spring(t, { tension = 200, friction = 22, delay = 0 } = {}) {
|
|
227
|
+
const dt = t - delay;
|
|
228
|
+
if (dt <= 0) return 0;
|
|
229
|
+
const omega = Math.sqrt(tension);
|
|
230
|
+
const zeta = friction / (2 * omega);
|
|
231
|
+
|
|
232
|
+
if (zeta >= 1) {
|
|
233
|
+
// Overdamped or critically damped — no overshoot
|
|
234
|
+
const r1 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));
|
|
235
|
+
const r2 = -omega * (zeta + Math.sqrt(zeta * zeta - 1));
|
|
236
|
+
const c2 = r1 / (r1 - r2);
|
|
237
|
+
const c1 = 1 - c2;
|
|
238
|
+
return 1 - c1 * Math.exp(r1 * dt) - c2 * Math.exp(r2 * dt);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Underdamped — oscillates toward 1
|
|
242
|
+
const omegaD = omega * Math.sqrt(1 - zeta * zeta);
|
|
243
|
+
return 1 - Math.exp(-zeta * omega * dt) * (
|
|
244
|
+
Math.cos(omegaD * dt) + (zeta / Math.sqrt(1 - zeta * zeta)) * Math.sin(omegaD * dt)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let _BOILN = 0;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* boilSeed(key, [t]) — Deterministic pseudo-random seed generator for linework boil.
|
|
252
|
+
*
|
|
253
|
+
* Boil holds drawing for 2 frames (12 fps), but moving elements consume randomness,
|
|
254
|
+
* shifting random streams for later elements. boilSeed restarts stream from BOILN + key
|
|
255
|
+
* so still elements never jitter.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} key Unique stable name of element (e.g. 'bg', 'character', 'prop')
|
|
258
|
+
* @param {number} [t] Current time (optional, updates internal BOILN = floor(t * 12))
|
|
259
|
+
* @returns {number} Deterministic float in [0, 1)
|
|
260
|
+
*/
|
|
261
|
+
export function boilSeed(key = 'frame', t = null) {
|
|
262
|
+
if (t !== null && typeof t === 'number') {
|
|
263
|
+
_BOILN = Math.floor(t * 12);
|
|
264
|
+
}
|
|
265
|
+
let h = 2166136261;
|
|
266
|
+
const str = `${key}|${_BOILN}`;
|
|
267
|
+
for (let i = 0; i < str.length; i++) {
|
|
268
|
+
h = Math.imul(h ^ str.charCodeAt(i), 16777619);
|
|
269
|
+
}
|
|
270
|
+
return (h >>> 0) / 4294967296;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Step time on twos (12 fps hold step) */
|
|
274
|
+
export function onTwos(t) {
|
|
275
|
+
return Math.floor(t * 12 + 1e-6) / 12;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Segment normalization: clamps (t - a) / (b - a) in [0, 1] */
|
|
279
|
+
export function seg(t, a, b) {
|
|
280
|
+
if (b === a) return 0;
|
|
281
|
+
return Math.max(0, Math.min(1, (t - a) / (b - a)));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Exponential beat pulse */
|
|
285
|
+
export function pulse(t, k = 6) {
|
|
286
|
+
const frac = t - Math.floor(t);
|
|
287
|
+
return Math.exp(-frac * k);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Keyframes with closed-form interpolation (scalar numbers or arrays).
|
|
292
|
+
* @param {number} t
|
|
293
|
+
* @param {Array<[number, number|number[]]>} keys e.g. [[0, 0], [1, 100], [2, 50]]
|
|
294
|
+
* @param {function} [e=easeInOut] Easing function
|
|
295
|
+
*/
|
|
296
|
+
export function kf(t, keys, e = easeInOut) {
|
|
297
|
+
if (!keys || keys.length === 0) return 0;
|
|
298
|
+
if (t <= keys[0][0]) return keys[0][1];
|
|
299
|
+
for (let i = 1; i < keys.length; i++) {
|
|
300
|
+
if (t < keys[i][0]) {
|
|
301
|
+
const [a, va] = keys[i - 1];
|
|
302
|
+
const [b, vb] = keys[i];
|
|
303
|
+
const prog = (t - a) / (b - a);
|
|
304
|
+
const k = e(Math.max(0, Math.min(1, prog)));
|
|
305
|
+
if (Array.isArray(va) && Array.isArray(vb)) {
|
|
306
|
+
return va.map((v, idx) => v + ((vb[idx] ?? v) - v) * k);
|
|
307
|
+
}
|
|
308
|
+
return va + (vb - va) * k;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return keys[keys.length - 1][1];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Register sequential timeline shots: [[t0, fn0], [t1, fn1], ...]
|
|
316
|
+
* Each fn(t, lt, dur) paints the frame when t is in its window.
|
|
317
|
+
*/
|
|
318
|
+
export function shots(shotList = []) {
|
|
319
|
+
for (let i = 0; i < shotList.length; i++) {
|
|
320
|
+
const [start, fn] = shotList[i];
|
|
321
|
+
const end = i + 1 < shotList.length ? shotList[i + 1][0] : REEL.duration;
|
|
322
|
+
layer(`shot_${i}`, { start, end }).on(fn);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Component builders ────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Plain animated text element.
|
|
330
|
+
* @param {string} str
|
|
331
|
+
* @param {{ font, color, x, y, size, weight, align, opacity, duration }} opts
|
|
332
|
+
*/
|
|
333
|
+
export function text(str, {
|
|
334
|
+
font = '48px/1.3 system-ui',
|
|
335
|
+
color = '#ffffff',
|
|
336
|
+
x = 'center',
|
|
337
|
+
y = 100,
|
|
338
|
+
size = null,
|
|
339
|
+
weight = null,
|
|
340
|
+
align = 'center',
|
|
341
|
+
opacity = 1,
|
|
342
|
+
duration = Infinity,
|
|
343
|
+
} = {}) {
|
|
344
|
+
const el = document.createElement('div');
|
|
345
|
+
el._duration = duration;
|
|
346
|
+
el.textContent = str;
|
|
347
|
+
|
|
348
|
+
const fontSize = size ? `${size}px` : font.split('/')[0].trim();
|
|
349
|
+
const fontFam = font.includes(' ') ? font : `${fontSize} system-ui`;
|
|
350
|
+
|
|
351
|
+
Object.assign(el.style, {
|
|
352
|
+
position: 'absolute',
|
|
353
|
+
font: fontFam,
|
|
354
|
+
fontSize: size ? `${size}px` : undefined,
|
|
355
|
+
fontWeight: weight ? String(weight) : undefined,
|
|
356
|
+
color,
|
|
357
|
+
textAlign: align,
|
|
358
|
+
opacity: String(opacity),
|
|
359
|
+
whiteSpace: 'pre-line',
|
|
360
|
+
maxWidth: '90%',
|
|
361
|
+
lineHeight: '1.3',
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
_positionEl(el, x, y);
|
|
365
|
+
return el;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Pill-shaped chip / label.
|
|
370
|
+
* @param {string} str
|
|
371
|
+
* @param {{ color, bg, x, y, size, duration }} opts
|
|
372
|
+
*/
|
|
373
|
+
export function chip(str, {
|
|
374
|
+
color = '#ffffff',
|
|
375
|
+
bg = 'rgba(59,130,246,0.85)',
|
|
376
|
+
x = 'center',
|
|
377
|
+
y = 80,
|
|
378
|
+
size = 20,
|
|
379
|
+
duration = Infinity,
|
|
380
|
+
} = {}) {
|
|
381
|
+
const el = document.createElement('div');
|
|
382
|
+
el._duration = duration;
|
|
383
|
+
el.textContent = str;
|
|
384
|
+
Object.assign(el.style, {
|
|
385
|
+
position: 'absolute',
|
|
386
|
+
display: 'inline-block',
|
|
387
|
+
padding: '6px 18px',
|
|
388
|
+
borderRadius: '9999px',
|
|
389
|
+
background: bg,
|
|
390
|
+
color,
|
|
391
|
+
fontSize: `${size}px`,
|
|
392
|
+
fontWeight: '600',
|
|
393
|
+
letterSpacing: '0.04em',
|
|
394
|
+
textTransform: 'uppercase',
|
|
395
|
+
});
|
|
396
|
+
_positionEl(el, x, y);
|
|
397
|
+
return el;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Frosted-glass card with stacked text lines.
|
|
402
|
+
* @param {{ text, size, weight, color }[]} lines
|
|
403
|
+
* @param {{ x, y, width, duration, bg, blur, radius, padding }} opts
|
|
404
|
+
*/
|
|
405
|
+
export function wallCard(lines = [], {
|
|
406
|
+
x = 'center',
|
|
407
|
+
y = REEL.height * 0.72,
|
|
408
|
+
width = Math.round(REEL.width * 0.84),
|
|
409
|
+
duration = Infinity,
|
|
410
|
+
bg = 'rgba(0,0,0,0.62)',
|
|
411
|
+
blur = 12,
|
|
412
|
+
radius = 16,
|
|
413
|
+
padding = 28,
|
|
414
|
+
} = {}) {
|
|
415
|
+
const el = document.createElement('div');
|
|
416
|
+
el._duration = duration;
|
|
417
|
+
Object.assign(el.style, {
|
|
418
|
+
position: 'absolute',
|
|
419
|
+
width: `${width}px`,
|
|
420
|
+
backdropFilter: `blur(${blur}px)`,
|
|
421
|
+
background: bg,
|
|
422
|
+
borderRadius: `${radius}px`,
|
|
423
|
+
padding: `${padding}px ${padding + 8}px`,
|
|
424
|
+
boxSizing: 'border-box',
|
|
425
|
+
display: 'flex',
|
|
426
|
+
flexDirection: 'column',
|
|
427
|
+
gap: '8px',
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
const p = document.createElement('p');
|
|
432
|
+
p.textContent = line.text || '';
|
|
433
|
+
Object.assign(p.style, {
|
|
434
|
+
margin: 0,
|
|
435
|
+
fontSize: `${line.size || 24}px`,
|
|
436
|
+
fontWeight: String(line.weight || 400),
|
|
437
|
+
color: line.color || '#ffffff',
|
|
438
|
+
lineHeight: '1.4',
|
|
439
|
+
});
|
|
440
|
+
el.appendChild(p);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_positionEl(el, x, y);
|
|
444
|
+
return el;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Image with optional caption overlay.
|
|
449
|
+
* @param {string} src URL or relative path
|
|
450
|
+
* @param {{ x, y, width, height, caption, radius, duration }} opts
|
|
451
|
+
*/
|
|
452
|
+
export function imageCard(src, {
|
|
453
|
+
x = 'center',
|
|
454
|
+
y = 'center',
|
|
455
|
+
width = 640,
|
|
456
|
+
height = 360,
|
|
457
|
+
caption = '',
|
|
458
|
+
radius = 12,
|
|
459
|
+
duration = Infinity,
|
|
460
|
+
} = {}) {
|
|
461
|
+
const el = document.createElement('div');
|
|
462
|
+
el._duration = duration;
|
|
463
|
+
Object.assign(el.style, {
|
|
464
|
+
position: 'absolute',
|
|
465
|
+
width: `${width}px`,
|
|
466
|
+
height: `${height}px`,
|
|
467
|
+
borderRadius: `${radius}px`,
|
|
468
|
+
overflow: 'hidden',
|
|
469
|
+
display: 'flex',
|
|
470
|
+
flexDirection:'column',
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const img = document.createElement('img');
|
|
474
|
+
img.src = src;
|
|
475
|
+
Object.assign(img.style, { width: '100%', height: caption ? 'calc(100% - 40px)' : '100%', objectFit: 'cover', display: 'block' });
|
|
476
|
+
el.appendChild(img);
|
|
477
|
+
|
|
478
|
+
if (caption) {
|
|
479
|
+
const cap = document.createElement('div');
|
|
480
|
+
cap.textContent = caption;
|
|
481
|
+
Object.assign(cap.style, {
|
|
482
|
+
background: 'rgba(0,0,0,0.7)', color: '#fff',
|
|
483
|
+
fontSize: '18px', padding: '8px 12px', textAlign: 'center',
|
|
484
|
+
});
|
|
485
|
+
el.appendChild(cap);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
_positionEl(el, x, y);
|
|
489
|
+
return el;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Animated terminal block.
|
|
494
|
+
* @param {string[]} lines
|
|
495
|
+
* @param {{ x, y, width, duration, bg, color }} opts
|
|
496
|
+
*/
|
|
497
|
+
export function terminal(lines = [], {
|
|
498
|
+
x = 100,
|
|
499
|
+
y = 'center',
|
|
500
|
+
width = 720,
|
|
501
|
+
duration = Infinity,
|
|
502
|
+
bg = 'rgba(0,0,0,0.88)',
|
|
503
|
+
color = '#22c55e',
|
|
504
|
+
} = {}) {
|
|
505
|
+
const el = document.createElement('div');
|
|
506
|
+
el._duration = duration;
|
|
507
|
+
Object.assign(el.style, {
|
|
508
|
+
position: 'absolute',
|
|
509
|
+
width: `${width}px`,
|
|
510
|
+
background: bg,
|
|
511
|
+
borderRadius: '10px',
|
|
512
|
+
padding: '20px 24px',
|
|
513
|
+
fontFamily: '"Fira Code", "Cascadia Code", monospace',
|
|
514
|
+
fontSize: '18px',
|
|
515
|
+
lineHeight: '1.7',
|
|
516
|
+
color,
|
|
517
|
+
boxSizing: 'border-box',
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Bar
|
|
521
|
+
const bar = document.createElement('div');
|
|
522
|
+
Object.assign(bar.style, { display: 'flex', gap: '8px', marginBottom: '14px' });
|
|
523
|
+
for (const c of ['#ef4444', '#f59e0b', '#22c55e']) {
|
|
524
|
+
const dot = document.createElement('div');
|
|
525
|
+
Object.assign(dot.style, { width: '12px', height: '12px', borderRadius: '50%', background: c });
|
|
526
|
+
bar.appendChild(dot);
|
|
527
|
+
}
|
|
528
|
+
el.appendChild(bar);
|
|
529
|
+
|
|
530
|
+
for (const ln of lines) {
|
|
531
|
+
const p = document.createElement('p');
|
|
532
|
+
p.textContent = ln;
|
|
533
|
+
Object.assign(p.style, { margin: '0', wordBreak: 'break-all' });
|
|
534
|
+
el.appendChild(p);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
_positionEl(el, x, y);
|
|
538
|
+
return el;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Diagonal stamp (VERIFIED, SOURCE, DRAFT, etc.)
|
|
543
|
+
* @param {string} str
|
|
544
|
+
* @param {{ color, size, x, y, duration }} opts
|
|
545
|
+
*/
|
|
546
|
+
export function stamp(str, {
|
|
547
|
+
color = 'rgba(220,38,38,0.8)',
|
|
548
|
+
size = 80,
|
|
549
|
+
x = 'center',
|
|
550
|
+
y = 'center',
|
|
551
|
+
duration = Infinity,
|
|
552
|
+
} = {}) {
|
|
553
|
+
const el = document.createElement('div');
|
|
554
|
+
el._duration = duration;
|
|
555
|
+
el.textContent = str.toUpperCase();
|
|
556
|
+
Object.assign(el.style, {
|
|
557
|
+
position: 'absolute',
|
|
558
|
+
fontSize: `${size}px`,
|
|
559
|
+
fontWeight: '900',
|
|
560
|
+
letterSpacing: '0.1em',
|
|
561
|
+
color,
|
|
562
|
+
border: `4px solid ${color}`,
|
|
563
|
+
padding: '6px 20px',
|
|
564
|
+
transform: 'rotate(-15deg)',
|
|
565
|
+
borderRadius: '4px',
|
|
566
|
+
textTransform: 'uppercase',
|
|
567
|
+
pointerEvents: 'none',
|
|
568
|
+
userSelect: 'none',
|
|
569
|
+
});
|
|
570
|
+
_positionEl(el, x, y);
|
|
571
|
+
return el;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Syntax-highlighted code block.
|
|
576
|
+
* @param {string} code
|
|
577
|
+
* @param {string} lang (informational only — no runtime highlight library required)
|
|
578
|
+
* @param {{ x, y, width, fontSize, duration }} opts
|
|
579
|
+
*/
|
|
580
|
+
export function codeCard(code, lang = 'js', {
|
|
581
|
+
x = 'center',
|
|
582
|
+
y = 'center',
|
|
583
|
+
width = 820,
|
|
584
|
+
fontSize = 20,
|
|
585
|
+
duration = Infinity,
|
|
586
|
+
} = {}) {
|
|
587
|
+
const el = document.createElement('div');
|
|
588
|
+
el._duration = duration;
|
|
589
|
+
Object.assign(el.style, {
|
|
590
|
+
position: 'absolute',
|
|
591
|
+
width: `${width}px`,
|
|
592
|
+
background: '#0d1117',
|
|
593
|
+
borderRadius: '12px',
|
|
594
|
+
overflow: 'hidden',
|
|
595
|
+
border: '1px solid rgba(255,255,255,0.1)',
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Header bar
|
|
599
|
+
const bar = document.createElement('div');
|
|
600
|
+
bar.textContent = lang;
|
|
601
|
+
Object.assign(bar.style, {
|
|
602
|
+
background: '#161b22', color: '#8b949e', fontSize: '14px',
|
|
603
|
+
padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
604
|
+
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.06em',
|
|
605
|
+
});
|
|
606
|
+
el.appendChild(bar);
|
|
607
|
+
|
|
608
|
+
const pre = document.createElement('pre');
|
|
609
|
+
Object.assign(pre.style, {
|
|
610
|
+
margin: 0, padding: '20px 22px', overflowX: 'auto',
|
|
611
|
+
fontFamily: '"Fira Code","Cascadia Code",monospace',
|
|
612
|
+
fontSize: `${fontSize}px`, lineHeight: '1.65',
|
|
613
|
+
color: '#e6edf3',
|
|
614
|
+
});
|
|
615
|
+
pre.textContent = code;
|
|
616
|
+
el.appendChild(pre);
|
|
617
|
+
|
|
618
|
+
_positionEl(el, x, y);
|
|
619
|
+
return el;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Browser screenshot card. Requires the URL to be a local data: URI or
|
|
624
|
+
* pre-captured image path (capture.mjs generates these).
|
|
625
|
+
*/
|
|
626
|
+
export function browserCard(src, {
|
|
627
|
+
x = 'center',
|
|
628
|
+
y = 'center',
|
|
629
|
+
width = 860,
|
|
630
|
+
duration = Infinity,
|
|
631
|
+
} = {}) {
|
|
632
|
+
return imageCard(src, { x, y, width, height: Math.round(width * (600 / 1024)), duration });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── faceAt — talking-head layout helper ──────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Return CSS position styles for a talking-head layout.
|
|
639
|
+
* Pure function — no DOM side effects.
|
|
640
|
+
*
|
|
641
|
+
* @param {'FULL'|'SPLIT'|'PIP'} layout
|
|
642
|
+
* @param {{ width, height, side, pipPos, pipSize }} opts
|
|
643
|
+
*/
|
|
644
|
+
export function faceAt(layout, {
|
|
645
|
+
width = REEL.width,
|
|
646
|
+
height = REEL.height,
|
|
647
|
+
side = 'right',
|
|
648
|
+
pipPos = 'br',
|
|
649
|
+
pipSize = 0.28,
|
|
650
|
+
} = {}) {
|
|
651
|
+
switch (layout.toUpperCase()) {
|
|
652
|
+
case 'FULL':
|
|
653
|
+
return { position: 'absolute', inset: 0, width: '100%', height: '100%' };
|
|
654
|
+
case 'SPLIT': {
|
|
655
|
+
const half = Math.round(width / 2);
|
|
656
|
+
const left = side === 'left' ? 0 : half;
|
|
657
|
+
return { position: 'absolute', top: 0, left, width: half, height };
|
|
658
|
+
}
|
|
659
|
+
case 'PIP': {
|
|
660
|
+
const pw = Math.round(width * pipSize);
|
|
661
|
+
const ph = Math.round(pw * (height / width));
|
|
662
|
+
const pad = 40;
|
|
663
|
+
const top = pipPos.startsWith('t') ? pad : height - ph - pad;
|
|
664
|
+
const left = pipPos.endsWith('l') ? pad : width - pw - pad;
|
|
665
|
+
return { position: 'absolute', top, left, width: pw, height: ph, borderRadius: 16 };
|
|
666
|
+
}
|
|
667
|
+
default:
|
|
668
|
+
return { position: 'absolute', inset: 0 };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── captions — word-level caption sync ───────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Register a Whisper outline file for auto-synced captions.
|
|
676
|
+
* The outline file is produced by scripts/transcribe.py
|
|
677
|
+
* Format: one JSON object per line: { "start": 0.0, "end": 0.5, "text": "word" }
|
|
678
|
+
* or a single JSON array of the same objects.
|
|
679
|
+
*
|
|
680
|
+
* @param {string} outlinePath Path relative to project root
|
|
681
|
+
*/
|
|
682
|
+
export function captions(outlinePath) {
|
|
683
|
+
fetch(outlinePath)
|
|
684
|
+
.then(r => r.text())
|
|
685
|
+
.then(raw => {
|
|
686
|
+
try {
|
|
687
|
+
_captionData = JSON.parse(raw);
|
|
688
|
+
} catch {
|
|
689
|
+
// NDJSON — one segment per line
|
|
690
|
+
_captionData = raw.trim().split('\n')
|
|
691
|
+
.filter(Boolean)
|
|
692
|
+
.map(l => JSON.parse(l));
|
|
693
|
+
}
|
|
694
|
+
})
|
|
695
|
+
.catch(e => console.warn('[engine] captions(): could not load', outlinePath, e));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let _captionEl = null;
|
|
699
|
+
|
|
700
|
+
function _tickCaptions(t) {
|
|
701
|
+
if (!_captionEl) {
|
|
702
|
+
_captionEl = document.createElement('div');
|
|
703
|
+
Object.assign(_captionEl.style, {
|
|
704
|
+
position: 'absolute',
|
|
705
|
+
bottom: '80px',
|
|
706
|
+
left: '50%',
|
|
707
|
+
transform: 'translateX(-50%)',
|
|
708
|
+
background: 'rgba(0,0,0,0.72)',
|
|
709
|
+
color: '#fff',
|
|
710
|
+
fontSize: '32px',
|
|
711
|
+
fontWeight: '600',
|
|
712
|
+
padding: '10px 28px',
|
|
713
|
+
borderRadius: '8px',
|
|
714
|
+
maxWidth: '80%',
|
|
715
|
+
textAlign: 'center',
|
|
716
|
+
lineHeight: '1.4',
|
|
717
|
+
pointerEvents: 'none',
|
|
718
|
+
});
|
|
719
|
+
document.getElementById('stage').appendChild(_captionEl);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Collect segments whose window overlaps t
|
|
723
|
+
const active = _captionData
|
|
724
|
+
.filter(seg => seg.start <= t && t < seg.end + 0.05)
|
|
725
|
+
.map(seg => seg.text)
|
|
726
|
+
.join(' ');
|
|
727
|
+
|
|
728
|
+
_captionEl.textContent = active;
|
|
729
|
+
_captionEl.style.display = active ? '' : 'none';
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── cue — sound effect placement & caption synchronization ───────────────────────
|
|
733
|
+
|
|
734
|
+
const _cues = [];
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Register a sound-effect cue. The audio is triggered by render.mjs when
|
|
738
|
+
* encoding audio tracks (not in headless Playwright frames).
|
|
739
|
+
*
|
|
740
|
+
* @param {string} sfxName Base name (no extension) inside assets/sfx/
|
|
741
|
+
* @param {number} t Time to trigger (seconds)
|
|
742
|
+
* @param {number} vol Volume 0–1 (default 1)
|
|
743
|
+
*/
|
|
744
|
+
export function cue(sfxName, t, vol = 1) {
|
|
745
|
+
_cues.push({ sfxName, t, vol });
|
|
746
|
+
// Expose on window for render.mjs to read after page load
|
|
747
|
+
window.__cues = _cues;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Match a target word or phrase in loaded captions, returning its start timestamp.
|
|
752
|
+
* Throws a fatal build error if the phrase cannot be found.
|
|
753
|
+
*
|
|
754
|
+
* @param {string} targetPhrase Exact word or sequence of words spoken in the voiceover
|
|
755
|
+
* @param {number} [offset=0] Offset in seconds to shift the trigger relative to the phrase start
|
|
756
|
+
* @returns {number} Timestamp in seconds
|
|
757
|
+
*/
|
|
758
|
+
export function cuePhrase(targetPhrase, offset = 0) {
|
|
759
|
+
if (!_captionData || !Array.isArray(_captionData) || _captionData.length === 0) {
|
|
760
|
+
throw new Error(`FATAL SYNC ERROR: Cannot cue "${targetPhrase}" — no captions loaded yet! Call captions() first.`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const targetWords = targetPhrase.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
764
|
+
if (targetWords.length === 0) return offset;
|
|
765
|
+
|
|
766
|
+
// Flatten caption segments into word tokens
|
|
767
|
+
const tokens = [];
|
|
768
|
+
for (const seg of _captionData) {
|
|
769
|
+
if (Array.isArray(seg.words)) {
|
|
770
|
+
for (const w of seg.words) {
|
|
771
|
+
tokens.push({
|
|
772
|
+
word: (w.word || w.text || '').toLowerCase().replace(/[^a-z0-9]/g, ''),
|
|
773
|
+
start: w.start !== undefined ? w.start : seg.start,
|
|
774
|
+
end: w.end !== undefined ? w.end : seg.end
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
const segWords = (seg.text || '').trim().split(/\s+/).filter(Boolean);
|
|
779
|
+
const segDur = Math.max(0.1, (seg.end - seg.start) || 1);
|
|
780
|
+
const wordDur = segDur / Math.max(1, segWords.length);
|
|
781
|
+
for (let i = 0; i < segWords.length; i++) {
|
|
782
|
+
tokens.push({
|
|
783
|
+
word: segWords[i].toLowerCase().replace(/[^a-z0-9]/g, ''),
|
|
784
|
+
start: seg.start + i * wordDur,
|
|
785
|
+
end: seg.start + (i + 1) * wordDur
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
for (let i = 0; i <= tokens.length - targetWords.length; i++) {
|
|
792
|
+
let match = true;
|
|
793
|
+
for (let j = 0; j < targetWords.length; j++) {
|
|
794
|
+
if (tokens[i + j].word !== targetWords[j]) {
|
|
795
|
+
match = false;
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (match) {
|
|
800
|
+
return tokens[i].start + offset;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
throw new Error(`FATAL SYNC ERROR: Phrase "${targetPhrase}" not found in captions track!`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ── on() — raw per-frame hook ─────────────────────────────────────────────────
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Register a raw per-frame callback (no associated DOM element).
|
|
811
|
+
* @param {(t: number) => void} fn
|
|
812
|
+
*/
|
|
813
|
+
export function on(fn) {
|
|
814
|
+
_layers.push({ _tick: fn, name: '_raw_' + _layers.length });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── Private helpers ───────────────────────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
function _positionEl(el, x, y) {
|
|
820
|
+
if (x === 'center') {
|
|
821
|
+
el.style.left = '50%';
|
|
822
|
+
el.style.transform = (el.style.transform || '') + ' translateX(-50%)';
|
|
823
|
+
} else {
|
|
824
|
+
el.style.left = `${x}px`;
|
|
825
|
+
}
|
|
826
|
+
if (y === 'center') {
|
|
827
|
+
el.style.top = '50%';
|
|
828
|
+
el.style.transform = (el.style.transform || '') + ' translateY(-50%)';
|
|
829
|
+
} else {
|
|
830
|
+
el.style.top = `${y}px`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function _stylesToCSS(styles) {
|
|
835
|
+
const mapped = {};
|
|
836
|
+
for (const [k, v] of Object.entries(styles)) {
|
|
837
|
+
if (v === null || v === undefined) continue;
|
|
838
|
+
if (typeof v === 'number') {
|
|
839
|
+
// Numeric pixel values for known layout props
|
|
840
|
+
const pixelProps = new Set([
|
|
841
|
+
'top','left','right','bottom','width','height',
|
|
842
|
+
'marginTop','marginBottom','marginLeft','marginRight',
|
|
843
|
+
'paddingTop','paddingBottom','paddingLeft','paddingRight',
|
|
844
|
+
'borderRadius','fontSize','gap',
|
|
845
|
+
]);
|
|
846
|
+
mapped[k] = pixelProps.has(k) ? `${v}px` : String(v);
|
|
847
|
+
} else {
|
|
848
|
+
mapped[k] = String(v);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return mapped;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── Browser live-preview loop (development only) ──────────────────────────────
|
|
855
|
+
// When loaded in a normal browser (not Playwright headless), drive the
|
|
856
|
+
// compositor from requestAnimationFrame using the real clock.
|
|
857
|
+
|
|
858
|
+
if (typeof window !== 'undefined' && !window.__playwright) {
|
|
859
|
+
let _startTime = null;
|
|
860
|
+
function _raf(ts) {
|
|
861
|
+
if (_startTime === null) _startTime = ts;
|
|
862
|
+
const t = Math.min((ts - _startTime) / 1000, REEL.duration);
|
|
863
|
+
seek(t);
|
|
864
|
+
if (t < REEL.duration) requestAnimationFrame(_raf);
|
|
865
|
+
}
|
|
866
|
+
window.addEventListener('load', () => {
|
|
867
|
+
if (!window.seek) window.seek = seek;
|
|
868
|
+
requestAnimationFrame(_raf);
|
|
869
|
+
});
|
|
870
|
+
}
|