@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.
Files changed (52) hide show
  1. package/.mcp.json +2 -4
  2. package/CHANGELOG.md +20 -1
  3. package/CLAUDE.md +154 -18
  4. package/README.md +16 -12
  5. package/SECURITY.md +1 -3
  6. package/install.sh +1 -1
  7. package/package.json +7 -3
  8. package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
  9. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
  10. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
  11. package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
  12. package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
  13. package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
  14. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
  15. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
  16. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
  17. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
  18. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
  19. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
  20. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
  21. package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
  22. package/scripts/db-migrate.js +112 -0
  23. package/scripts/deep-validate.js +326 -0
  24. package/scripts/generate-initial-assets.js +342 -0
  25. package/scripts/html-renderer/new-project.mjs +408 -0
  26. package/scripts/html-renderer/transcribe.py +294 -0
  27. package/src/asset-library/README.md +2 -2
  28. package/src/asset-library/asset-library.js +1 -1
  29. package/src/cli/doctor.js +31 -1
  30. package/src/cli/make.js +52 -71
  31. package/src/cli/setup-keys.js +24 -19
  32. package/src/fact-checker/review-gate.js +5 -1
  33. package/src/harness/bin/harness.mjs +324 -0
  34. package/src/harness/lib/browser.mjs +137 -0
  35. package/src/harness/lib/capture.mjs +161 -0
  36. package/src/harness/lib/encode.mjs +89 -0
  37. package/src/harness/lib/jobs.mjs +135 -0
  38. package/src/harness/lib/pixels.mjs +137 -0
  39. package/src/harness/lib/sheets.mjs +164 -0
  40. package/src/harness/lib/verify-purity.mjs +119 -0
  41. package/src/harness/test/fixtures/broken-scene.html +27 -0
  42. package/src/harness/test/fixtures/reference-scene.html +23 -0
  43. package/src/harness/test/fixtures/sample-script.json +18 -0
  44. package/src/mcp-server/server.js +318 -66
  45. package/src/pipeline/external-adapter.js +55 -15
  46. package/src/pipeline/providers.js +46 -0
  47. package/src/pipeline/telemetry.js +1 -0
  48. package/src/utils/log-redaction.js +5 -3
  49. package/src/video-renderer/renderer.js +12 -4
  50. package/src/worker/server-render-free.js +2 -3
  51. package/src/workflows/assets.js +20 -43
  52. 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
+ }