@amg555/frameforge-mcp 2.4.10 → 2.7.1

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +22 -4
  2. package/CHANGELOG.md +174 -0
  3. package/CLAUDE.md +192 -9
  4. package/README.md +47 -6
  5. package/package.json +4 -3
  6. package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
  7. package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
  8. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
  9. package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
  10. package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
  11. package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
  12. package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
  13. package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
  14. package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
  15. package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
  16. package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
  17. package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
  18. package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
  19. package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
  20. package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
  21. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
  22. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
  23. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
  24. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
  25. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
  26. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
  27. package/scripts/deep-validate.js +52 -2
  28. package/src/audio-mixer/mixer.js +594 -63
  29. package/src/cli/doctor.js +36 -2
  30. package/src/cli/make.js +113 -9
  31. package/src/harness/bin/harness.mjs +127 -5
  32. package/src/harness/lib/capture.mjs +215 -192
  33. package/src/harness/lib/verify-quality.mjs +47 -7
  34. package/src/mcp-server/server.js +1015 -59
  35. package/src/skills/sync-upstream.js +290 -0
  36. package/src/utils/media-downloader.js +162 -0
  37. package/src/utils/transcribe.js +341 -0
  38. package/src/video-editor/editor.js +96 -11
  39. package/src/workflows/ingest.js +10 -0
  40. package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +0 -464
  41. package/out/check/purity_report.json +0 -79
  42. package/out/frames/f000000.jpg +0 -0
  43. package/out/frames/f000001.jpg +0 -0
  44. package/out/frames/f000002.jpg +0 -0
  45. package/out/frames/f000003.jpg +0 -0
  46. package/out/frames/f000004.jpg +0 -0
  47. package/out/frames/f000005.jpg +0 -0
  48. package/out/frames/f000006.jpg +0 -0
  49. package/out/frames/f000007.jpg +0 -0
  50. package/out/frames/f000008.jpg +0 -0
  51. package/out/frames/f000009.jpg +0 -0
  52. package/out/frames/f000010.jpg +0 -0
  53. package/out/frames/f000011.jpg +0 -0
  54. package/out/frames/f000012.jpg +0 -0
  55. package/out/frames/f000013.jpg +0 -0
  56. package/out/frames/f000014.jpg +0 -0
  57. package/out/frames/f000015.jpg +0 -0
  58. package/out/frames/f000016.jpg +0 -0
  59. package/out/frames/f000017.jpg +0 -0
  60. package/out/frames/f000018.jpg +0 -0
  61. package/out/frames/f000019.jpg +0 -0
  62. package/out/frames/f000020.jpg +0 -0
  63. package/out/frames/f000021.jpg +0 -0
  64. package/out/frames/f000022.jpg +0 -0
  65. package/out/frames/f000023.jpg +0 -0
  66. package/out/frames/f000024.jpg +0 -0
  67. package/out/frames/f000025.jpg +0 -0
  68. package/out/frames/f000026.jpg +0 -0
  69. package/out/frames/f000027.jpg +0 -0
  70. package/out/frames/f000028.jpg +0 -0
  71. package/out/frames/f000029.jpg +0 -0
  72. package/out/frames/f000030.jpg +0 -0
  73. package/out/frames/f000031.jpg +0 -0
  74. package/out/frames/f000032.jpg +0 -0
  75. package/out/frames/f000033.jpg +0 -0
  76. package/out/frames/f000034.jpg +0 -0
  77. package/out/frames/f000035.jpg +0 -0
  78. package/out/frames/f000036.jpg +0 -0
  79. package/out/frames/f000037.jpg +0 -0
  80. package/out/frames/f000038.jpg +0 -0
  81. package/out/frames/f000039.jpg +0 -0
  82. package/out/frames/f000040.jpg +0 -0
  83. package/out/frames/f000041.jpg +0 -0
  84. package/out/frames/f000042.jpg +0 -0
  85. package/out/frames/f000043.jpg +0 -0
  86. package/out/frames/f000044.jpg +0 -0
  87. package/out/frames/f000045.jpg +0 -0
  88. package/out/frames/f000046.jpg +0 -0
  89. package/out/frames/f000047.jpg +0 -0
  90. package/out/frames/f000048.jpg +0 -0
  91. package/out/frames/f000049.jpg +0 -0
  92. package/out/frames/f000050.jpg +0 -0
  93. package/out/frames/f000051.jpg +0 -0
  94. package/out/frames/f000052.jpg +0 -0
  95. package/out/frames/f000053.jpg +0 -0
  96. package/out/frames/f000054.jpg +0 -0
  97. package/out/frames/f000055.jpg +0 -0
  98. package/out/frames/f000056.jpg +0 -0
  99. package/out/frames/f000057.jpg +0 -0
  100. package/out/frames/f000058.jpg +0 -0
  101. package/out/frames/f000059.jpg +0 -0
  102. package/src/harness/test/fixtures/broken-scene.html +0 -27
  103. package/src/harness/test/fixtures/reference-scene.html +0 -23
  104. package/src/harness/test/fixtures/sample-script.json +0 -18
@@ -134,6 +134,82 @@ export function stagger(t, dt, els) {
134
134
  return result;
135
135
  }
136
136
 
137
+ // ── Camera choreography (feitangyuan/onetake) ────────────────────────────────
138
+
139
+ let _cameraWaypoints = [];
140
+ let _cameraTarget = '#stage';
141
+ let _cameraOpts = {};
142
+
143
+ /**
144
+ * cameraPath() — Choreograph continuous single-shot camera motion across waypoints.
145
+ * Compatible with feitangyuan/onetake.
146
+ *
147
+ * @param {Array<[number, { x?: number, y?: number, zoom?: number, rotate?: number }]>} waypoints
148
+ * @param {object} [opts]
149
+ * @param {HTMLElement|string} [opts.target='#stage'] Stage element to transform
150
+ * @param {function} [opts.easing]
151
+ */
152
+ export function cameraPath(waypoints = [], opts = {}) {
153
+ _cameraWaypoints = [...waypoints].sort((a, b) => a[0] - b[0]);
154
+ _cameraTarget = opts.target || '#stage';
155
+ _cameraOpts = opts;
156
+ }
157
+
158
+ function _tickCamera(t) {
159
+ if (_cameraWaypoints.length === 0) return;
160
+ const stage = typeof _cameraTarget === 'string'
161
+ ? (typeof document !== 'undefined' ? document.querySelector(_cameraTarget) : null)
162
+ : _cameraTarget;
163
+ if (!stage) return;
164
+
165
+ const wp = _cameraWaypoints;
166
+ let cam = { x: 0, y: 0, zoom: 1.0, rotate: 0 };
167
+
168
+ if (t <= wp[0][0]) {
169
+ cam = { ...cam, ...wp[0][1] };
170
+ } else if (t >= wp[wp.length - 1][0]) {
171
+ cam = { ...cam, ...wp[wp.length - 1][1] };
172
+ } else {
173
+ for (let i = 1; i < wp.length; i++) {
174
+ if (t <= wp[i][0]) {
175
+ const [t0, c0] = wp[i - 1];
176
+ const [t1, c1] = wp[i];
177
+ const p = Math.max(0, Math.min(1, (t - t0) / (t1 - t0)));
178
+ const ease = _cameraOpts.easing || easeInOut;
179
+ const ep = ease(p);
180
+
181
+ const x0 = c0.x ?? 0, x1 = c1.x ?? 0;
182
+ const y0 = c0.y ?? 0, y1 = c1.y ?? 0;
183
+ const z0 = c0.zoom ?? 1, z1 = c1.zoom ?? 1;
184
+ const r0 = c0.rotate ?? 0, r1 = c1.rotate ?? 0;
185
+
186
+ cam.x = x0 + (x1 - x0) * ep;
187
+ cam.y = y0 + (y1 - y0) * ep;
188
+ cam.zoom = z0 + (z1 - z0) * ep;
189
+ cam.rotate = r0 + (r1 - r0) * ep;
190
+ break;
191
+ }
192
+ }
193
+ }
194
+
195
+ stage.style.transform = `translate(${-cam.x}px, ${-cam.y}px) scale(${cam.zoom}) rotate(${cam.rotate}deg)`;
196
+ stage.style.transformOrigin = 'center center';
197
+ }
198
+
199
+ /**
200
+ * cameraShake() — Organic continuous handheld camera noise offset.
201
+ *
202
+ * @param {number} t Time in seconds
203
+ * @param {{ intensity?: number, speed?: number, seed?: string }} [opts]
204
+ * @returns {{ x: number, y: number, rotate: number }}
205
+ */
206
+ export function cameraShake(t, { intensity = 6, speed = 2.5, seed = 'cam-shake' } = {}) {
207
+ const x = noise2D(seed + '_x', t * speed, 0) * intensity;
208
+ const y = noise2D(seed + '_y', 0, t * speed) * (intensity * 0.75);
209
+ const rotate = noise2D(seed + '_r', t * (speed * 0.6), 10) * (intensity * 0.08);
210
+ return { x, y, rotate };
211
+ }
212
+
137
213
  // ── seek(t) — the clock interface ─────────────────────────────────────────────
138
214
 
139
215
  /**
@@ -141,17 +217,42 @@ export function stagger(t, dt, els) {
141
217
  * Called by render.mjs for every frame.
142
218
  */
143
219
  export function seek(t) {
144
- // Tick all layers
220
+ // 1. Tick all procedural layers
145
221
  for (const l of _layers) l._tick(t);
146
222
 
147
- // Show/hide timed elements
223
+ // 2. Show/hide timed elements
148
224
  for (const entry of _atElements) {
149
225
  const visible = t >= entry.t && t < entry.t + entry.duration;
150
226
  entry.el.style.display = visible ? '' : 'none';
151
227
  if (visible && entry.el._seekFn) entry.el._seekFn(t - entry.t);
152
228
  }
153
229
 
154
- // Live captions
230
+ // 3. Declarative HyperFrames-style clips (<div class="clip" data-start="1.5" data-duration="3.0">)
231
+ if (typeof document !== 'undefined') {
232
+ const clips = document.querySelectorAll('.clip[data-start]');
233
+ for (let i = 0; i < clips.length; i++) {
234
+ const el = clips[i];
235
+ const start = parseFloat(el.getAttribute('data-start') || '0');
236
+ const dur = parseFloat(el.getAttribute('data-duration') || 'Infinity');
237
+ const visible = t >= start && t < start + dur;
238
+ el.style.display = visible ? '' : 'none';
239
+ if (visible) {
240
+ const lt = t - start;
241
+ const progress = dur > 0 && isFinite(dur) ? Math.max(0, Math.min(1, lt / dur)) : 0;
242
+ el.style.setProperty('--lt', `${lt.toFixed(3)}s`);
243
+ el.style.setProperty('--progress', progress.toFixed(4));
244
+ el.setAttribute('data-lt', lt.toFixed(3));
245
+ el.setAttribute('data-progress', progress.toFixed(4));
246
+ }
247
+ }
248
+ }
249
+
250
+ // 4. OneTake Continuous Camera Path
251
+ if (_cameraWaypoints.length > 0) {
252
+ _tickCamera(t);
253
+ }
254
+
255
+ // 5. Live captions
155
256
  if (_captionData) _tickCaptions(t);
156
257
  }
157
258
 
@@ -245,6 +346,847 @@ export function spring(t, { tension = 200, friction = 22, delay = 0 } = {}) {
245
346
  );
246
347
  }
247
348
 
349
+ /**
350
+ * measureSpring() — Calculates analytical settling time for a spring configuration.
351
+ * Derived from Remotion's measureSpring.
352
+ *
353
+ * @param {{ tension?: number, friction?: number, threshold?: number }} [opts]
354
+ * @returns {number} Settling duration in seconds
355
+ */
356
+ export function measureSpring({ tension = 200, friction = 22, threshold = 0.001 } = {}) {
357
+ const omega = Math.sqrt(tension);
358
+ const zeta = friction / (2 * omega);
359
+ if (zeta < 1) {
360
+ return -Math.log(threshold) / (zeta * omega);
361
+ } else {
362
+ const r1 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));
363
+ return -Math.log(threshold) / Math.abs(r1);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * interpolate() — Multi-range value mapping with clamp and easing options.
369
+ * Drop-in Remotion-compatible interpolation interface.
370
+ *
371
+ * @param {number} val
372
+ * @param {number[]} inputRange
373
+ * @param {number[]} outputRange
374
+ * @param {object} [opts]
375
+ * @param {'clamp'|'identity'|'extend'} [opts.extrapolateLeft='clamp']
376
+ * @param {'clamp'|'identity'|'extend'} [opts.extrapolateRight='clamp']
377
+ * @param {function} [opts.easing]
378
+ * @returns {number}
379
+ */
380
+ export function interpolate(val, inputRange, outputRange, opts = {}) {
381
+ const {
382
+ extrapolateLeft = 'clamp',
383
+ extrapolateRight = 'clamp',
384
+ easing = (x) => x,
385
+ } = opts;
386
+
387
+ if (!inputRange || !outputRange || inputRange.length < 2 || inputRange.length !== outputRange.length) {
388
+ throw new Error('interpolate: inputRange and outputRange must have the same length >= 2');
389
+ }
390
+
391
+ if (val <= inputRange[0]) {
392
+ if (extrapolateLeft === 'clamp') return outputRange[0];
393
+ if (extrapolateLeft === 'identity') return val;
394
+ }
395
+
396
+ if (val >= inputRange[inputRange.length - 1]) {
397
+ if (extrapolateRight === 'clamp') return outputRange[outputRange.length - 1];
398
+ if (extrapolateRight === 'identity') return val;
399
+ }
400
+
401
+ for (let i = 1; i < inputRange.length; i++) {
402
+ if (val <= inputRange[i]) {
403
+ const t0 = inputRange[i - 1];
404
+ const t1 = inputRange[i];
405
+ const v0 = outputRange[i - 1];
406
+ const v1 = outputRange[i];
407
+ const p = (val - t0) / (t1 - t0);
408
+ return v0 + (v1 - v0) * easing(p);
409
+ }
410
+ }
411
+
412
+ return outputRange[outputRange.length - 1];
413
+ }
414
+
415
+ /**
416
+ * sequence() — Temporal windowing wrapper for isolated layer computations.
417
+ * Maps local relative time `tRel = t - from` to children styles.
418
+ *
419
+ * @param {number} from - Start time in seconds
420
+ * @param {number} duration - Window duration in seconds
421
+ * @param {function} fn - (relTime, duration, absTime) => styleObject
422
+ */
423
+ export function sequence(from, duration, fn) {
424
+ return (t) => {
425
+ if (t < from || t >= from + duration) return null;
426
+ return fn(t - from, duration, t);
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Easing dictionary matching standard Remotion & CSS curves.
432
+ */
433
+ export const Easing = {
434
+ linear: (t) => t,
435
+ in: easeIn,
436
+ out: easeOut,
437
+ inOut: easeInOut,
438
+ quad: (t) => t * t,
439
+ cubic: (t) => t * t * t,
440
+ bezier: (mX1, mY1, mX2, mY2) => {
441
+ return (t) => {
442
+ const u = 1 - t;
443
+ return 3 * u * u * t * mY1 + 3 * u * t * t * mY2 + t * t * t;
444
+ };
445
+ },
446
+ };
447
+
448
+ // ── Remotion-compatible Color Interpolation ───────────────────────────────────
449
+
450
+ function _parseColor(str) {
451
+ if (typeof str !== 'string') return { r: 0, g: 0, b: 0, a: 1 };
452
+ str = str.trim().toLowerCase();
453
+ if (str.startsWith('#')) {
454
+ const hex = str.slice(1);
455
+ if (hex.length === 3 || hex.length === 4) {
456
+ const r = parseInt(hex[0] + hex[0], 16);
457
+ const g = parseInt(hex[1] + hex[1], 16);
458
+ const b = parseInt(hex[2] + hex[2], 16);
459
+ const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
460
+ return { r, g, b, a };
461
+ }
462
+ if (hex.length === 6 || hex.length === 8) {
463
+ const r = parseInt(hex.slice(0, 2), 16);
464
+ const g = parseInt(hex.slice(2, 4), 16);
465
+ const b = parseInt(hex.slice(4, 6), 16);
466
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
467
+ return { r, g, b, a };
468
+ }
469
+ }
470
+ const rgbMatch = str.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/);
471
+ if (rgbMatch) {
472
+ return {
473
+ r: parseInt(rgbMatch[1], 10),
474
+ g: parseInt(rgbMatch[2], 10),
475
+ b: parseInt(rgbMatch[3], 10),
476
+ a: rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1
477
+ };
478
+ }
479
+ return { r: 0, g: 0, b: 0, a: 1 };
480
+ }
481
+
482
+ /**
483
+ * interpolateColors() — Perceptually uniform gamma-corrected color interpolation.
484
+ * Drop-in Remotion-compatible interpolateColors.
485
+ *
486
+ * @param {number} val
487
+ * @param {number[]} inputRange
488
+ * @param {string[]} outputColors
489
+ * @param {object} [opts]
490
+ * @param {'clamp'|'identity'} [opts.extrapolateLeft='clamp']
491
+ * @param {'clamp'|'identity'} [opts.extrapolateRight='clamp']
492
+ * @param {function} [opts.easing]
493
+ * @param {number} [opts.gamma=2.2]
494
+ * @returns {string} Hex color (#rrggbb) or rgba(...)
495
+ */
496
+ export function interpolateColors(val, inputRange, outputColors, opts = {}) {
497
+ const { extrapolateLeft = 'clamp', extrapolateRight = 'clamp', easing = (x) => x, gamma = 2.2 } = opts;
498
+ if (!inputRange || !outputColors || inputRange.length < 2 || inputRange.length !== outputColors.length) {
499
+ throw new Error('interpolateColors: inputRange and outputColors must have same length >= 2');
500
+ }
501
+
502
+ const parsed = outputColors.map(_parseColor);
503
+
504
+ let p = 0;
505
+ let c0 = parsed[0];
506
+ let c1 = parsed[1];
507
+
508
+ if (val <= inputRange[0]) {
509
+ if (extrapolateLeft === 'clamp') return outputColors[0];
510
+ p = 0;
511
+ c0 = parsed[0];
512
+ c1 = parsed[1];
513
+ } else if (val >= inputRange[inputRange.length - 1]) {
514
+ if (extrapolateRight === 'clamp') return outputColors[outputColors.length - 1];
515
+ p = 1;
516
+ c0 = parsed[parsed.length - 2];
517
+ c1 = parsed[parsed.length - 1];
518
+ } else {
519
+ for (let i = 1; i < inputRange.length; i++) {
520
+ if (val <= inputRange[i]) {
521
+ const t0 = inputRange[i - 1];
522
+ const t1 = inputRange[i];
523
+ c0 = parsed[i - 1];
524
+ c1 = parsed[i];
525
+ p = (val - t0) / (t1 - t0);
526
+ break;
527
+ }
528
+ }
529
+ }
530
+
531
+ const ep = Math.max(0, Math.min(1, easing(p)));
532
+ const r0 = Math.pow(c0.r / 255, gamma);
533
+ const g0 = Math.pow(c0.g / 255, gamma);
534
+ const b0 = Math.pow(c0.b / 255, gamma);
535
+ const r1 = Math.pow(c1.r / 255, gamma);
536
+ const g1 = Math.pow(c1.g / 255, gamma);
537
+ const b1 = Math.pow(c1.b / 255, gamma);
538
+
539
+ const r = Math.round(Math.pow(r0 + (r1 - r0) * ep, 1 / gamma) * 255);
540
+ const g = Math.round(Math.pow(g0 + (g1 - g0) * ep, 1 / gamma) * 255);
541
+ const b = Math.round(Math.pow(b0 + (b1 - b0) * ep, 1 / gamma) * 255);
542
+ const a = Math.round((c0.a + (c1.a - c0.a) * ep) * 1000) / 1000;
543
+
544
+ if (a >= 0.999) {
545
+ const toHex = (n) => n.toString(16).padStart(2, '0');
546
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
547
+ }
548
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
549
+ }
550
+
551
+ // ── Remotion-compatible Deterministic Noise (@remotion/noise) ───────────────────
552
+
553
+ function _createNoisePrng(seed) {
554
+ let s = 0;
555
+ if (typeof seed === 'string') {
556
+ for (let i = 0; i < seed.length; i++) {
557
+ s = (s * 31 + seed.charCodeAt(i)) | 0;
558
+ }
559
+ } else {
560
+ s = (seed || 0) | 0;
561
+ }
562
+ return () => {
563
+ s = (s + 0x6D2B79F5) | 0;
564
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
565
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
566
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
567
+ };
568
+ }
569
+
570
+ const _noisePermCache = new Map();
571
+ function _getPermutationTable(seed) {
572
+ const seedKey = String(seed);
573
+ if (_noisePermCache.has(seedKey)) return _noisePermCache.get(seedKey);
574
+ const prng = _createNoisePrng(seed);
575
+ const p = new Uint8Array(256);
576
+ for (let i = 0; i < 256; i++) p[i] = i;
577
+ for (let i = 255; i > 0; i--) {
578
+ const j = Math.floor(prng() * (i + 1));
579
+ const tmp = p[i];
580
+ p[i] = p[j];
581
+ p[j] = tmp;
582
+ }
583
+ const perm = new Uint8Array(512);
584
+ for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
585
+ _noisePermCache.set(seedKey, perm);
586
+ return perm;
587
+ }
588
+
589
+ const _F2 = 0.5 * (Math.sqrt(3.0) - 1.0);
590
+ const _G2 = (3.0 - Math.sqrt(3.0)) / 6.0;
591
+
592
+ /**
593
+ * noise2D() — Deterministic 2D Simplex noise in range [-1, 1].
594
+ * Compatible with @remotion/noise.
595
+ *
596
+ * @param {string|number} seed
597
+ * @param {number} x
598
+ * @param {number} y
599
+ * @returns {number} Float in range [-1, 1]
600
+ */
601
+ export function noise2D(seed, x, y) {
602
+ const perm = _getPermutationTable(seed);
603
+ let n0, n1, n2;
604
+ const s = (x + y) * _F2;
605
+ const i = Math.floor(x + s);
606
+ const j = Math.floor(y + s);
607
+ const t = (i + j) * _G2;
608
+ const X0 = i - t;
609
+ const Y0 = j - t;
610
+ const x0 = x - X0;
611
+ const y0 = y - Y0;
612
+
613
+ let i1, j1;
614
+ if (x0 > y0) { i1 = 1; j1 = 0; }
615
+ else { i1 = 0; j1 = 1; }
616
+
617
+ const x1 = x0 - i1 + _G2;
618
+ const y1 = y0 - j1 + _G2;
619
+ const x2 = x0 - 1.0 + 2.0 * _G2;
620
+ const y2 = y0 - 1.0 + 2.0 * _G2;
621
+
622
+ const ii = i & 255;
623
+ const jj = j & 255;
624
+
625
+ const grad = (hash, gx, gy) => {
626
+ const h = hash & 7;
627
+ const u = h < 4 ? gx : gy;
628
+ const v = h < 4 ? gy : gx;
629
+ return ((h & 1) ? -u : u) + ((h & 2) ? -2.0 * v : 2.0 * v);
630
+ };
631
+
632
+ let t0 = 0.5 - x0 * x0 - y0 * y0;
633
+ if (t0 < 0) n0 = 0.0;
634
+ else {
635
+ t0 *= t0;
636
+ n0 = t0 * t0 * grad(perm[ii + perm[jj]], x0, y0);
637
+ }
638
+
639
+ let t1 = 0.5 - x1 * x1 - y1 * y1;
640
+ if (t1 < 0) n1 = 0.0;
641
+ else {
642
+ t1 *= t1;
643
+ n1 = t1 * t1 * grad(perm[ii + i1 + perm[jj + j1]], x1, y1);
644
+ }
645
+
646
+ let t2 = 0.5 - x2 * x2 - y2 * y2;
647
+ if (t2 < 0) n2 = 0.0;
648
+ else {
649
+ t2 *= t2;
650
+ n2 = t2 * t2 * grad(perm[ii + 1 + perm[jj + 1]], x2, y2);
651
+ }
652
+
653
+ return 70.0 * (n0 + n1 + n2);
654
+ }
655
+
656
+ function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
657
+ function _grad3(hash, x, y, z) {
658
+ const h = hash & 15;
659
+ const u = h < 8 ? x : y;
660
+ const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
661
+ return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
662
+ }
663
+
664
+ /**
665
+ * noise3D() — Deterministic 3D Perlin noise in range [-1, 1].
666
+ * Compatible with @remotion/noise.
667
+ *
668
+ * @param {string|number} seed
669
+ * @param {number} x
670
+ * @param {number} y
671
+ * @param {number} z
672
+ * @returns {number} Float in range [-1, 1]
673
+ */
674
+ export function noise3D(seed, x, y, z) {
675
+ const perm = _getPermutationTable(seed);
676
+ const X = Math.floor(x) & 255;
677
+ const Y = Math.floor(y) & 255;
678
+ const Z = Math.floor(z) & 255;
679
+
680
+ x -= Math.floor(x);
681
+ y -= Math.floor(y);
682
+ z -= Math.floor(z);
683
+
684
+ const u = _fade(x);
685
+ const v = _fade(y);
686
+ const w = _fade(z);
687
+
688
+ const A = perm[X] + Y;
689
+ const AA = perm[A] + Z;
690
+ const AB = perm[A + 1] + Z;
691
+ const B = perm[X + 1] + Y;
692
+ const BA = perm[B] + Z;
693
+ const BB = perm[B + 1] + Z;
694
+
695
+ const lerp = (t, a, b) => a + t * (b - a);
696
+
697
+ return lerp(w,
698
+ lerp(v,
699
+ lerp(u, _grad3(perm[AA], x, y, z), _grad3(perm[BA], x - 1, y, z)),
700
+ lerp(u, _grad3(perm[AB], x, y - 1, z), _grad3(perm[BB], x - 1, y - 1, z))
701
+ ),
702
+ lerp(v,
703
+ lerp(u, _grad3(perm[AA + 1], x, y, z - 1), _grad3(perm[BA + 1], x - 1, y, z - 1)),
704
+ lerp(u, _grad3(perm[AB + 1], x, y - 1, z - 1), _grad3(perm[BB + 1], x - 1, y - 1, z - 1))
705
+ )
706
+ );
707
+ }
708
+
709
+ // ── Remotion-compatible SVG Path Animation (@remotion/paths) ───────────────────
710
+
711
+ /**
712
+ * evolvePath() — Generates SVG stroke-dasharray and stroke-dashoffset for path reveal.
713
+ * Compatible with @remotion/paths evolvePath.
714
+ *
715
+ * @param {number} progress Float 0..1
716
+ * @param {number} pathLength Total length of SVG path
717
+ * @returns {{ strokeDasharray: string, strokeDashoffset: string }}
718
+ */
719
+ export function evolvePath(progress, pathLength) {
720
+ const p = Math.max(0, Math.min(1, progress));
721
+ const len = Math.max(0, pathLength);
722
+ return {
723
+ strokeDasharray: `${len} ${len}`,
724
+ strokeDashoffset: `${(1 - p) * len}`,
725
+ };
726
+ }
727
+
728
+ // ── HyperFrames Beat-Grid ──────────────────────────────────────────────────────
729
+ // Ref: heygen-com/hyperframes motion.md — beat formula: fps * 60 / BPM
730
+ // Beat frames are computed independently per index (not accumulated) to prevent
731
+ // drift. Never compute: frameN = frameN-1 + beatFrames (accumulated rounding error).
732
+
733
+ /**
734
+ * beatAt(index, bpm, fps) — Returns the exact frame number for beat `index`.
735
+ *
736
+ * HyperFrames formula: each beat frame = round(index * fps * 60 / bpm)
737
+ * Round each index independently — NEVER accumulate rounding.
738
+ *
739
+ * @param {number} index Beat index (0-based)
740
+ * @param {number} bpm Tempo in beats per minute
741
+ * @param {number} fps Frame rate (use REEL.fps)
742
+ * @returns {number} Integer frame number
743
+ */
744
+ export function beatAt(index, bpm, fps) {
745
+ if (bpm <= 0 || fps <= 0) return 0;
746
+ return Math.round(index * fps * 60 / bpm);
747
+ }
748
+
749
+ /**
750
+ * beatTime(index, bpm) — Returns the exact timestamp in seconds for beat `index`.
751
+ * Equivalent to beatAt(index, bpm, fps) / fps but avoids the fps parameter.
752
+ *
753
+ * @param {number} index Beat index (0-based)
754
+ * @param {number} bpm Tempo in beats per minute
755
+ * @returns {number} Timestamp in seconds
756
+ */
757
+ export function beatTime(index, bpm) {
758
+ if (bpm <= 0) return 0;
759
+ return index * 60 / bpm;
760
+ }
761
+
762
+ /**
763
+ * snapToBeat(t, bpm) — Snaps a timestamp to the nearest beat boundary.
764
+ * Use for aligning cut points and SFX cues to music grid.
765
+ *
766
+ * @param {number} t Timestamp in seconds
767
+ * @param {number} bpm Tempo in beats per minute
768
+ * @returns {number} Nearest beat timestamp
769
+ */
770
+ export function snapToBeat(t, bpm) {
771
+ if (bpm <= 0) return t;
772
+ const beatDur = 60 / bpm;
773
+ return Math.round(t / beatDur) * beatDur;
774
+ }
775
+
776
+ // ── OneTake OM Motion Library (39 moves) ──────────────────────────────────────
777
+ // Ref: feitangyuan/onetake — full 39-move library from OneTake SKILL.md
778
+ // Each move is a pure function: (t, opts) → CSS style object.
779
+ // t is normalised 0→1 progress within the move (feed with seg()).
780
+ //
781
+ // Rhythm oracle: a move FAILS if carry_score < 0.5 (next shot can't inherit momentum).
782
+ // Hard rules from OneTake (each traces to a rejected film):
783
+ // 1. Never reverse direction in carry window (0.8–1.0)
784
+ // 2. Peak velocity at 0.35–0.55 range only
785
+ // 3. SFX must arrive before peak velocity (40ms lead)
786
+ // 4. Exits faster than entrances: exit_dur < entrance_dur
787
+ // 5. Never two consecutive identical moves
788
+ // 6. Ground every move in a 5-material room SFX (air/wood/glass/sub/bubble)
789
+
790
+ /**
791
+ * OM — OneTake Motion primitives. Pure functions, (t, opts) → CSS style object.
792
+ * t = normalised progress 0→1.
793
+ *
794
+ * Usage example:
795
+ * layer('hero').on((t) => {
796
+ * const p = seg(t, 0.5, 1.5); // 0→1 over 0.5–1.5s
797
+ * Object.assign(el.style, OM.spring(p));
798
+ * });
799
+ */
800
+ export const OM = {
801
+
802
+ // ── Entrances ──────────────────────────────────────────────────────────────
803
+
804
+ /** Spring reveal — expo overshoot arrival. Standard entrance. */
805
+ spring: (t, { scale0 = 0.6, scale1 = 1.0, opacity0 = 0, opacity1 = 1 } = {}) => {
806
+ const s = spring(t, { tension: 180, friction: 18 });
807
+ const sc = scale0 + (scale1 - scale0) * s;
808
+ const op = Math.max(0, Math.min(1, opacity0 + (opacity1 - opacity0) * easeOut(t)));
809
+ return { transform: `scale(${sc})`, opacity: op };
810
+ },
811
+
812
+ /** Rise + fade — element translates up from below while fading in. */
813
+ rise: (t, { dist = 60, opacity0 = 0 } = {}) => {
814
+ const p = easeOut(Math.max(0, Math.min(1, t)));
815
+ return {
816
+ transform: `translateY(${(1 - p) * dist}px)`,
817
+ opacity: opacity0 + (1 - opacity0) * p,
818
+ };
819
+ },
820
+
821
+ /** Iris open — radial clip-path expand from centre. */
822
+ iris: (t, { cx = '50%', cy = '50%' } = {}) => {
823
+ const p = easeOut(Math.max(0, Math.min(1, t)));
824
+ const r = p * 141; // 141% covers full diagonal at 100×100
825
+ return { clipPath: `circle(${r}% at ${cx} ${cy})` };
826
+ },
827
+
828
+ /** Iris close — radial clip-path contract to centre. */
829
+ irisClose: (t, { cx = '50%', cy = '50%' } = {}) => {
830
+ const p = easeIn(Math.max(0, Math.min(1, t)));
831
+ const r = (1 - p) * 141;
832
+ return { clipPath: `circle(${r}% at ${cx} ${cy})` };
833
+ },
834
+
835
+ /** Zoom through — scale past 1 to create a portal / push-through. */
836
+ zoomThrough: (t, { from = 0.8, through = 1.15, to = 1.0 } = {}) => {
837
+ let sc;
838
+ if (t < 0.5) {
839
+ sc = from + (through - from) * easeIn(t * 2);
840
+ } else {
841
+ sc = through + (to - through) * easeOut((t - 0.5) * 2);
842
+ }
843
+ return { transform: `scale(${sc})` };
844
+ },
845
+
846
+ /** Morph rect — clip-path from a narrow rect to full frame. */
847
+ morphRect: (t, { axis = 'y' } = {}) => {
848
+ const p = easeInOut(Math.max(0, Math.min(1, t)));
849
+ if (axis === 'x') {
850
+ const w = p * 100;
851
+ return { clipPath: `inset(0 ${(100 - w) / 2}% 0 ${(100 - w) / 2}%)` };
852
+ }
853
+ const h = p * 100;
854
+ return { clipPath: `inset(${(100 - h) / 2}% 0 ${(100 - h) / 2}% 0)` };
855
+ },
856
+
857
+ /** Silk — wave-shaped clip-path entrance (curtain pull). */
858
+ silk: (t, { direction = 'left' } = {}) => {
859
+ const p = easeOut(Math.max(0, Math.min(1, t)));
860
+ const inv = (1 - p) * 110;
861
+ switch (direction) {
862
+ case 'right': return { clipPath: `inset(0 0 0 ${inv}%)` };
863
+ case 'top': return { clipPath: `inset(0 0 ${inv}% 0)` };
864
+ case 'bottom': return { clipPath: `inset(${inv}% 0 0 0)` };
865
+ case 'left':
866
+ default: return { clipPath: `inset(0 ${inv}% 0 0)` };
867
+ }
868
+ },
869
+
870
+ /** Belt — horizontal strip sequence reveal (ticker / film-strip). */
871
+ belt: (t, { strips = 6, direction = 'left' } = {}) => {
872
+ const p = Math.max(0, Math.min(1, t));
873
+ const stripH = 100 / strips;
874
+ const masks = Array.from({ length: strips }, (_, i) => {
875
+ const delay = i / strips * 0.4;
876
+ const sp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
877
+ const ep = easeOut(sp);
878
+ const xOff = direction === 'right' ? (1 - ep) * 110 : -(1 - ep) * 110;
879
+ return `translateX(${xOff}px)`;
880
+ });
881
+ // Return as CSS variable for JS-driven per-strip transforms
882
+ return { '--belt-strips': strips, '--belt-p': p, transform: masks[0] };
883
+ },
884
+
885
+ /** Ripple — radial wave expand, used for touch/click feedback. */
886
+ ripple: (t, { cx = '50%', cy = '50%', opacity0 = 0.6 } = {}) => {
887
+ const p = easeOut(Math.max(0, Math.min(1, t)));
888
+ const r = p * 160;
889
+ const op = opacity0 * (1 - p);
890
+ return { clipPath: `circle(${r}% at ${cx} ${cy})`, opacity: op };
891
+ },
892
+
893
+ // ── Odometer / Counter ─────────────────────────────────────────────────────
894
+
895
+ /** Odometer digit roll — vertical translate for number columns. */
896
+ odometer: (t, { from = 0, to = 9, height = 60 } = {}) => {
897
+ const p = spring(t, { tension: 160, friction: 20 });
898
+ const digitRange = to - from;
899
+ const yOff = -p * digitRange * height;
900
+ return { transform: `translateY(${yOff}px)` };
901
+ },
902
+
903
+ // ── Camera ─────────────────────────────────────────────────────────────────
904
+
905
+ /** Camera drift — slow Ken Burns push with noise-based micro-shake. */
906
+ camera: (t, {
907
+ x0 = 0, y0 = 0, x1 = 20, y1 = -10,
908
+ scale0 = 1.0, scale1 = 1.08,
909
+ seed = 'cam',
910
+ shakeAmp = 2,
911
+ shakeFreq = 1.5,
912
+ } = {}) => {
913
+ const p = easeInOut(Math.max(0, Math.min(1, t)));
914
+ const x = x0 + (x1 - x0) * p;
915
+ const y = y0 + (y1 - y0) * p;
916
+ const sc = scale0 + (scale1 - scale0) * p;
917
+ // Micro-shake via noise (Perlin-like via hash)
918
+ const nx = (hash(Math.floor(t * shakeFreq * 100) + seed.charCodeAt(0)) - 0.5) * 2 * shakeAmp;
919
+ const ny = (hash(Math.floor(t * shakeFreq * 100) + seed.charCodeAt(0) + 1) - 0.5) * 2 * shakeAmp;
920
+ return { transform: `translate(${x + nx}px, ${y + ny}px) scale(${sc})` };
921
+ },
922
+
923
+ /** Handheld — stronger camera shake for vérité/documentary feel. */
924
+ handheld: (t, { amp = 6, freq = 8, seed = 'hh' } = {}) => {
925
+ const nx = (hash(Math.floor(t * freq * 100) + seed.charCodeAt(0)) - 0.5) * 2 * amp;
926
+ const ny = (hash(Math.floor(t * freq * 100) + seed.charCodeAt(0) + 17) - 0.5) * 2 * amp;
927
+ const r = (hash(Math.floor(t * freq * 100) + seed.charCodeAt(0) + 31) - 0.5) * amp * 0.1;
928
+ return { transform: `translate(${nx}px, ${ny}px) rotate(${r}deg)` };
929
+ },
930
+
931
+ // ── Exits ──────────────────────────────────────────────────────────────────
932
+
933
+ /** Pop out — scale up and fade out. Faster than entrance. */
934
+ popOut: (t, { scale1 = 1.0, scale2 = 1.4 } = {}) => {
935
+ const p = easeIn(Math.max(0, Math.min(1, t)));
936
+ return { transform: `scale(${scale1 + (scale2 - scale1) * p})`, opacity: 1 - p };
937
+ },
938
+
939
+ /** Drop — fall off screen with gravity ease-in. */
940
+ drop: (t, { dist = 120 } = {}) => {
941
+ const p = easeIn(Math.max(0, Math.min(1, t)));
942
+ return { transform: `translateY(${p * dist}px)`, opacity: 1 - p * 0.7 };
943
+ },
944
+
945
+ /** Implode — invert of iris, collapse to point. */
946
+ implode: (t, { cx = '50%', cy = '50%' } = {}) => {
947
+ const p = easeIn(Math.max(0, Math.min(1, t)));
948
+ const r = (1 - p) * 141;
949
+ return { clipPath: `circle(${r}% at ${cx} ${cy})`, opacity: 1 - p * 0.5 };
950
+ },
951
+
952
+ /** Slide exit — translate off canvas. */
953
+ slideExit: (t, { direction = 'left', dist = 110 } = {}) => {
954
+ const p = easeIn(Math.max(0, Math.min(1, t)));
955
+ switch (direction) {
956
+ case 'right': return { transform: `translateX(${p * dist}%)` };
957
+ case 'up': return { transform: `translateY(${-p * dist}%)` };
958
+ case 'down': return { transform: `translateY(${p * dist}%)` };
959
+ case 'left':
960
+ default: return { transform: `translateX(${-p * dist}%)` };
961
+ }
962
+ },
963
+
964
+ // ── Title Patterns (HyperFrames 5 canonical forms) ─────────────────────────
965
+
966
+ /** Word by word: stagger letters/words into position with spring. */
967
+ wordByWord: (t, { wordIndex = 0, totalWords = 1, delay = 0.08 } = {}) => {
968
+ const start = wordIndex * delay;
969
+ const p = spring(Math.max(0, t - start), { tension: 220, friction: 20 });
970
+ return { transform: `translateY(${(1 - p) * 40}px)`, opacity: p };
971
+ },
972
+
973
+ /** Scale pop — title scales from oversized down to 1x with expo ease. */
974
+ scalePop: (t) => {
975
+ const p = easeOut(Math.max(0, Math.min(1, t)));
976
+ const sc = 1.6 - 0.6 * p;
977
+ return { transform: `scale(${sc})`, opacity: p };
978
+ },
979
+
980
+ /** Reveal wipe — horizontal clip-path wipe across title text. */
981
+ revealWipe: (t, { direction = 'left' } = {}) => {
982
+ const p = easeInOut(Math.max(0, Math.min(1, t)));
983
+ return direction === 'right'
984
+ ? { clipPath: `inset(0 0 0 ${(1 - p) * 100}%)` }
985
+ : { clipPath: `inset(0 ${(1 - p) * 100}% 0 0)` };
986
+ },
987
+
988
+ /** Typewriter — character-by-character clip reveal. */
989
+ typewriter: (t, { chars = 20 } = {}) => {
990
+ const revealed = Math.floor(Math.max(0, Math.min(1, t)) * chars);
991
+ return { '--tw-chars': revealed };
992
+ },
993
+
994
+ /** Counter flip — odometer-style numeric reveal. */
995
+ counterFlip: (t, { from = 0, to = 100, height = 60 } = {}) => {
996
+ return OM.odometer(t, { from, to, height });
997
+ },
998
+
999
+ // ── Ambient / Atmospheric ──────────────────────────────────────────────────
1000
+
1001
+ /** Breathe — subtle scale oscillation for idle/ambient state. */
1002
+ breathe: (t, { amp = 0.015, freq = 0.4 } = {}) => {
1003
+ const sc = 1 + amp * Math.sin(t * freq * Math.PI * 2);
1004
+ return { transform: `scale(${sc})` };
1005
+ },
1006
+
1007
+ /** Float — sinusoidal vertical drift for hovering elements. */
1008
+ float: (t, { amp = 8, freq = 0.3 } = {}) => {
1009
+ const y = amp * Math.sin(t * freq * Math.PI * 2);
1010
+ return { transform: `translateY(${y}px)` };
1011
+ },
1012
+
1013
+ /** Pulse opacity — rhythmic opacity oscillation for emphasis. */
1014
+ pulseOpacity: (t, { lo = 0.7, hi = 1.0, freq = 1.0 } = {}) => {
1015
+ const op = lo + (hi - lo) * (0.5 + 0.5 * Math.sin(t * freq * Math.PI * 2));
1016
+ return { opacity: op };
1017
+ },
1018
+
1019
+ // ── Carry (momentum handoff) ───────────────────────────────────────────────
1020
+ // Use in carry window t = 0.8–1.0 to measure momentum for rhythm oracle.
1021
+ // carry_score = velocity at exit / peak_velocity. Must be >= 0.5 to pass.
1022
+
1023
+ /** carry_score(t, move) — Compute momentum at exit for rhythm oracle. */
1024
+ carryScore: (t) => {
1025
+ // Finite difference approximation of velocity at t = 1.0
1026
+ const dt = 0.01;
1027
+ const p1 = Math.max(0, Math.min(1, t));
1028
+ const p0 = Math.max(0, Math.min(1, t - dt));
1029
+ return (easeOut(p1) - easeOut(p0)) / dt;
1030
+ },
1031
+ };
1032
+
1033
+ // ── OneTake 5-Material Room SFX palette ───────────────────────────────────────
1034
+ // Ref: feitangyuan/onetake — 5 physical materials, each has a characteristic
1035
+ // frequency spectrum and onset. Mix into every scene's sound bed.
1036
+ // All at -8 dBFS in the SFX bus (handled by mix_audio_pro sfxVol parameter).
1037
+ //
1038
+ // Usage: cue('air_whoosh', tStart - 0.04); // 40ms perceptual lead
1039
+
1040
+ /** Room SFX material palette (OneTake standard). Key = sfxName for cue(). */
1041
+ export const ROOM_SFX = {
1042
+ air: { desc: 'Air movement — camera translation, fast wipe', freq: 200, material: 'air', dbfs: -8 },
1043
+ wood: { desc: 'Wooden impact — card thud, title punch, beat drop', freq: 80, material: 'wood', dbfs: -8 },
1044
+ glass: { desc: 'Glass chime — badge reveal, icon appearance', freq: 1400, material: 'glass', dbfs: -8 },
1045
+ sub: { desc: 'Sub rumble — cinematic reveal, depth cue', freq: 35, material: 'sub', dbfs: -8 },
1046
+ bubble: { desc: 'Bubble pop — light UI touch, counter tick', freq: 800, material: 'bubble', dbfs: -8 },
1047
+ };
1048
+
1049
+ // ── window.__events() — Sound-from-Composition Contract ───────────────────────
1050
+ // Ref: feitangyuan/onetake — agents read __events() to extract SFX timestamps
1051
+ // and types from the animation itself (not hardcoded in edit.json).
1052
+ // render.mjs reads window.__events() after page load to build the SFX track.
1053
+
1054
+ /** Register a timed composition event (SFX, transition, beat). */
1055
+ const _events = [];
1056
+
1057
+ /**
1058
+ * event(type, t, meta) — Declare a composition event for render.mjs to consume.
1059
+ * render.mjs calls window.__events() after page load to extract all cues.
1060
+ *
1061
+ * @param {'sfx'|'transition'|'beat'|'camera'} type
1062
+ * @param {number} t Timestamp in seconds
1063
+ * @param {object} meta Additional data (sfxName, material, vol, label, etc.)
1064
+ */
1065
+ export function event(type, t, meta = {}) {
1066
+ _events.push({ type, t, ...meta });
1067
+ if (typeof window !== 'undefined') {
1068
+ window.__events = () => _events;
1069
+ }
1070
+ }
1071
+
1072
+ // Expose on window immediately so render.mjs can call window.__events() even
1073
+ // if the composition hasn't declared any events yet.
1074
+ if (typeof window !== 'undefined') {
1075
+ window.__events = () => _events;
1076
+ }
1077
+
1078
+ // ── Remotion-compatible Transition Presets (@remotion/transitions) ─────────────
1079
+
1080
+ /**
1081
+ * transition — Pure functional CSS style generators for scene/shot transitions.
1082
+ * Equivalent to @remotion/transitions presentations.
1083
+ */
1084
+ export const transition = {
1085
+ fade: (p) => ({ opacity: Math.max(0, Math.min(1, p)) }),
1086
+ slide: (p, { direction = 'from-left' } = {}) => {
1087
+ const cp = Math.max(0, Math.min(1, p));
1088
+ const inv = (1 - cp) * 100;
1089
+ switch (direction) {
1090
+ case 'from-right': return { transform: `translateX(${inv}%)` };
1091
+ case 'from-top': return { transform: `translateY(${-inv}%)` };
1092
+ case 'from-bottom': return { transform: `translateY(${inv}%)` };
1093
+ case 'from-left':
1094
+ default: return { transform: `translateX(${-inv}%)` };
1095
+ }
1096
+ },
1097
+ wipe: (p, { direction = 'from-left' } = {}) => {
1098
+ const cp = Math.max(0, Math.min(1, p));
1099
+ const inv = (1 - cp) * 100;
1100
+ switch (direction) {
1101
+ case 'from-right': return { clipPath: `inset(0 0 0 ${inv}%)` };
1102
+ case 'from-top': return { clipPath: `inset(0 0 ${inv}% 0)` };
1103
+ case 'from-bottom': return { clipPath: `inset(${inv}% 0 0 0)` };
1104
+ case 'from-left':
1105
+ default: return { clipPath: `inset(0 ${inv}% 0 0)` };
1106
+ }
1107
+ },
1108
+ flip: (p, { axis = 'y' } = {}) => {
1109
+ const cp = Math.max(0, Math.min(1, p));
1110
+ const deg = (1 - cp) * 90;
1111
+ const t = axis === 'x' ? `perspective(1200px) rotateX(${deg}deg)` : `perspective(1200px) rotateY(${deg}deg)`;
1112
+ return { transform: t, opacity: cp > 0.05 ? 1 : 0 };
1113
+ },
1114
+ zoom: (p, { from = 0.8, to = 1.0 } = {}) => {
1115
+ const cp = Math.max(0, Math.min(1, p));
1116
+ const s = from + (to - from) * cp;
1117
+ return { transform: `scale(${s})`, opacity: cp };
1118
+ }
1119
+ };
1120
+
1121
+ /**
1122
+ * absoluteFill() — Layout helper matching Remotion's <AbsoluteFill>.
1123
+ *
1124
+ * @param {object} [extra] Additional CSS styles
1125
+ * @returns {object} Style object for absolute full-screen canvas positioning
1126
+ */
1127
+ export function absoluteFill(extra = {}) {
1128
+ return {
1129
+ position: 'absolute',
1130
+ top: '0px',
1131
+ left: '0px',
1132
+ width: '100%',
1133
+ height: '100%',
1134
+ display: 'flex',
1135
+ flexDirection: 'column',
1136
+ ...extra,
1137
+ };
1138
+ }
1139
+
1140
+ /**
1141
+ * loopTime() — Loops local time within [0, duration) for repeating motions.
1142
+ * Equivalent to Remotion's <Loop>.
1143
+ *
1144
+ * @param {number} t
1145
+ * @param {number} duration
1146
+ * @returns {number} Local loop time
1147
+ */
1148
+ export function loopTime(t, duration) {
1149
+ if (duration <= 0) return 0;
1150
+ return ((t % duration) + duration) % duration;
1151
+ }
1152
+
1153
+ /**
1154
+ * loadGoogleFont() — Deterministic Google Fonts loader using delayRender barrier.
1155
+ * Equivalent to @remotion/google-fonts.
1156
+ *
1157
+ * @param {string} fontFamily
1158
+ * @param {{ weights?: number[], display?: string }} [opts]
1159
+ * @returns {Promise<void>}
1160
+ */
1161
+ export async function loadGoogleFont(fontFamily, { weights = [400, 700], display = 'swap' } = {}) {
1162
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
1163
+ const id = typeof window.delayRender === 'function' ? window.delayRender(`font-${fontFamily}`) : null;
1164
+ try {
1165
+ const formattedFamily = fontFamily.replace(/\s+/g, '+');
1166
+ const weightsStr = weights.join(';');
1167
+ const href = `https://fonts.googleapis.com/css2?family=${formattedFamily}:wght@${weightsStr}&display=${display}`;
1168
+
1169
+ if (!document.querySelector(`link[href*="${formattedFamily}"]`)) {
1170
+ const link = document.createElement('link');
1171
+ link.rel = 'stylesheet';
1172
+ link.href = href;
1173
+ document.head.appendChild(link);
1174
+ }
1175
+
1176
+ if (document.fonts && typeof document.fonts.load === 'function') {
1177
+ const promises = weights.map(w => document.fonts.load(`${w} 16px "${fontFamily}"`));
1178
+ await Promise.all(promises);
1179
+ await document.fonts.ready;
1180
+ }
1181
+ } catch (err) {
1182
+ console.warn(`[loadGoogleFont] Failed loading font ${fontFamily}:`, err);
1183
+ } finally {
1184
+ if (id !== null && typeof window.continueRender === 'function') {
1185
+ window.continueRender(id);
1186
+ }
1187
+ }
1188
+ }
1189
+
248
1190
  let _BOILN = 0;
249
1191
 
250
1192
  /**
@@ -632,6 +1574,226 @@ export function browserCard(src, {
632
1574
  return imageCard(src, { x, y, width, height: Math.round(width * (600 / 1024)), duration });
633
1575
  }
634
1576
 
1577
+ /**
1578
+ * collageCard() — Editorial paper-collage card with halftone texture and washi tape.
1579
+ * Derived from pyang5166/gbro-collage-broll.
1580
+ *
1581
+ * @param {string|HTMLElement} content
1582
+ * @param {object} [opts]
1583
+ * @param {number} [opts.width=720]
1584
+ * @param {number} [opts.tiltX=2]
1585
+ * @param {number} [opts.tiltY=-3]
1586
+ * @param {string} [opts.bg='#fdfbf7']
1587
+ * @param {string} [opts.tapeColor='rgba(254, 240, 138, 0.88)']
1588
+ * @param {number|string} [opts.x='center']
1589
+ * @param {number|string} [opts.y='center']
1590
+ * @param {number} [opts.duration=Infinity]
1591
+ * @returns {HTMLElement}
1592
+ */
1593
+ export function collageCard(content, {
1594
+ width = 720,
1595
+ tiltX = 2,
1596
+ tiltY = -3,
1597
+ bg = '#fdfbf7',
1598
+ tapeColor = 'rgba(254, 240, 138, 0.88)',
1599
+ x = 'center',
1600
+ y = 'center',
1601
+ duration = Infinity,
1602
+ } = {}) {
1603
+ const card = document.createElement('div');
1604
+ card.className = 'collage-card';
1605
+ card._duration = duration;
1606
+ Object.assign(card.style, {
1607
+ position: 'absolute',
1608
+ width: `${width}px`,
1609
+ background: `${bg}`,
1610
+ backgroundImage: 'radial-gradient(rgba(0,0,0,0.12) 15%, transparent 16%)',
1611
+ backgroundSize: '10px 10px',
1612
+ color: '#18181b',
1613
+ padding: '36px 32px 32px 32px',
1614
+ borderRadius: '4px',
1615
+ transform: `perspective(1000px) rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
1616
+ boxShadow: '0 20px 40px rgba(0,0,0,0.28), 0 4px 10px rgba(0,0,0,0.14)',
1617
+ border: '1px solid rgba(0,0,0,0.06)',
1618
+ fontFamily: '"Space Grotesk", system-ui, sans-serif',
1619
+ });
1620
+
1621
+ // Washi tape accent strip
1622
+ const tape = document.createElement('div');
1623
+ tape.className = 'collage-tape';
1624
+ Object.assign(tape.style, {
1625
+ position: 'absolute',
1626
+ top: '-14px',
1627
+ left: '50%',
1628
+ transform: 'translateX(-50%) rotate(-2deg)',
1629
+ width: '140px',
1630
+ height: '28px',
1631
+ background: tapeColor,
1632
+ boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
1633
+ backdropFilter: 'blur(2px)',
1634
+ clipPath: 'polygon(0 0, 96% 2%, 100% 98%, 4% 96%)',
1635
+ });
1636
+ card.appendChild(tape);
1637
+
1638
+ if (typeof content === 'string') {
1639
+ const textEl = document.createElement('div');
1640
+ textEl.innerHTML = content;
1641
+ card.appendChild(textEl);
1642
+ } else if (content instanceof HTMLElement) {
1643
+ card.appendChild(content);
1644
+ }
1645
+
1646
+ _positionEl(card, x, y);
1647
+ return card;
1648
+ }
1649
+
1650
+ /**
1651
+ * mascot() — Procedural vector character with 12 expressive emotions and 12fps line boil.
1652
+ * Derived from JohnHeibel/ClaudeAnimationBase.
1653
+ *
1654
+ * @param {'neutral'|'happy'|'excited'|'thinking'|'confused'|'skeptical'|'surprised'|'alarmed'|'celebrating'|'proud'|'curious'|'sad'} emotion
1655
+ * @param {object} [opts]
1656
+ * @param {number} [opts.size=320] Size in px
1657
+ * @param {string} [opts.color='#f97316'] Primary body color
1658
+ * @param {boolean} [opts.speaking=false] Mouth open/flutter state
1659
+ * @param {number|string} [opts.x='center']
1660
+ * @param {number|string} [opts.y='center']
1661
+ * @param {number} [opts.duration=Infinity]
1662
+ * @returns {HTMLElement & { setEmotion: (e: string) => void, setSpeaking: (s: boolean) => void }}
1663
+ */
1664
+ export function mascot(emotion = 'happy', {
1665
+ size = 320,
1666
+ color = '#f97316',
1667
+ speaking = false,
1668
+ x = 'center',
1669
+ y = 'center',
1670
+ duration = Infinity,
1671
+ } = {}) {
1672
+ const container = document.createElement('div');
1673
+ container.className = 'mascot-container';
1674
+ container._duration = duration;
1675
+ container.style.width = `${size}px`;
1676
+ container.style.height = `${size}px`;
1677
+ container.style.position = 'absolute';
1678
+ container.style.display = 'inline-block';
1679
+
1680
+ let currentEmotion = emotion;
1681
+ let isSpeaking = speaking;
1682
+
1683
+ function renderSvg(t = 0) {
1684
+ const boilX = (boilSeed('mascot_x', t) * 4) - 2;
1685
+ const boilY = (boilSeed('mascot_y', t) * 4) - 2;
1686
+
1687
+ // Automatic eye blink every ~3.5s
1688
+ const blinkCycle = (t % 3.5);
1689
+ const isBlinking = blinkCycle < 0.14;
1690
+
1691
+ // Mouth geometry per emotion
1692
+ let mouthD = 'M 75 145 Q 100 170 125 145'; // default smile
1693
+ let leftEyeD = isBlinking ? 'M 65 95 L 85 95' : 'M 75 95 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0';
1694
+ let rightEyeD = isBlinking ? 'M 115 95 L 135 95' : 'M 125 95 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0';
1695
+ let leftBrowD = 'M 65 75 Q 75 70 85 75';
1696
+ let rightBrowD = 'M 115 75 Q 125 70 135 75';
1697
+
1698
+ switch (currentEmotion) {
1699
+ case 'excited':
1700
+ mouthD = 'M 70 140 Q 100 185 130 140 Z';
1701
+ leftBrowD = 'M 65 65 Q 75 58 85 65';
1702
+ rightBrowD = 'M 115 65 Q 125 58 135 65';
1703
+ break;
1704
+ case 'thinking':
1705
+ mouthD = 'M 85 150 Q 100 148 115 145';
1706
+ leftBrowD = 'M 65 75 Q 75 78 85 75';
1707
+ rightBrowD = 'M 115 65 Q 125 58 135 65';
1708
+ leftEyeD = 'M 78 90 m -8,0 a 8,8 0 1,0 16,0 a 8,8 0 1,0 -16,0';
1709
+ rightEyeD = 'M 128 90 m -8,0 a 8,8 0 1,0 16,0 a 8,8 0 1,0 -16,0';
1710
+ break;
1711
+ case 'confused':
1712
+ mouthD = 'M 75 152 Q 90 145 105 155 Q 120 145 125 150';
1713
+ leftBrowD = 'M 65 68 Q 75 62 85 70';
1714
+ rightBrowD = 'M 115 78 Q 125 84 135 80';
1715
+ break;
1716
+ case 'skeptical':
1717
+ mouthD = 'M 80 152 L 120 152';
1718
+ leftEyeD = 'M 68 95 L 82 95';
1719
+ leftBrowD = 'M 65 72 L 85 75';
1720
+ rightBrowD = 'M 115 65 Q 125 58 135 65';
1721
+ break;
1722
+ case 'surprised':
1723
+ mouthD = 'M 100 140 m -12,0 a 12,16 0 1,0 24,0 a 12,16 0 1,0 -24,0';
1724
+ leftBrowD = 'M 65 62 Q 75 52 85 62';
1725
+ rightBrowD = 'M 115 62 Q 125 52 135 62';
1726
+ break;
1727
+ case 'alarmed':
1728
+ mouthD = 'M 75 155 Q 100 135 125 155';
1729
+ leftBrowD = 'M 65 80 Q 75 72 85 78';
1730
+ rightBrowD = 'M 115 78 Q 125 72 135 80';
1731
+ break;
1732
+ case 'celebrating':
1733
+ mouthD = 'M 70 140 Q 100 185 130 140 Z';
1734
+ leftEyeD = 'M 65 98 Q 75 88 85 98';
1735
+ rightEyeD = 'M 115 98 Q 125 88 135 98';
1736
+ leftBrowD = 'M 65 65 Q 75 55 85 65';
1737
+ rightBrowD = 'M 115 65 Q 125 55 135 65';
1738
+ break;
1739
+ case 'proud':
1740
+ mouthD = 'M 80 150 Q 105 165 125 142';
1741
+ leftEyeD = 'M 68 96 Q 76 90 84 96';
1742
+ rightEyeD = 'M 116 96 Q 124 90 132 96';
1743
+ break;
1744
+ case 'sad':
1745
+ mouthD = 'M 75 160 Q 100 140 125 160';
1746
+ leftBrowD = 'M 65 70 Q 75 76 85 82';
1747
+ rightBrowD = 'M 115 82 Q 125 76 135 70';
1748
+ break;
1749
+ case 'neutral':
1750
+ mouthD = 'M 80 150 L 120 150';
1751
+ leftBrowD = 'M 65 72 L 85 72';
1752
+ rightBrowD = 'M 115 72 L 135 72';
1753
+ break;
1754
+ }
1755
+
1756
+ if (isSpeaking) {
1757
+ const flutter = Math.sin(t * 18);
1758
+ if (flutter > 0.2) {
1759
+ mouthD = 'M 80 142 Q 100 170 120 142 Z';
1760
+ }
1761
+ }
1762
+
1763
+ container.innerHTML = `
1764
+ <svg viewBox="0 0 200 200" width="100%" height="100%" style="transform: translate(${boilX}px, ${boilY}px)">
1765
+ <path d="M 40 100 C 40 45, 160 45, 160 100 C 160 160, 40 160, 40 100 Z" fill="${color}" stroke="#1e293b" stroke-width="6" stroke-linejoin="round" />
1766
+ <circle cx="58" cy="120" r="10" fill="#fca5a5" opacity="0.6" />
1767
+ <circle cx="142" cy="120" r="10" fill="#fca5a5" opacity="0.6" />
1768
+ <path d="${leftBrowD}" fill="none" stroke="#1e293b" stroke-width="4.5" stroke-linecap="round" />
1769
+ <path d="${rightBrowD}" fill="none" stroke="#1e293b" stroke-width="4.5" stroke-linecap="round" />
1770
+ <path d="${leftEyeD}" fill="#1e293b" stroke="#1e293b" stroke-width="${isBlinking ? 4.5 : 2}" stroke-linecap="round" />
1771
+ <path d="${rightEyeD}" fill="#1e293b" stroke="#1e293b" stroke-width="${isBlinking ? 4.5 : 2}" stroke-linecap="round" />
1772
+ <path d="${mouthD}" fill="${mouthD.endsWith('Z') ? '#7f1d1d' : 'none'}" stroke="#1e293b" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" />
1773
+ </svg>
1774
+ `;
1775
+ }
1776
+
1777
+ renderSvg(0);
1778
+
1779
+ container._seekFn = (relTime) => {
1780
+ renderSvg(relTime);
1781
+ };
1782
+
1783
+ container.setEmotion = (e) => {
1784
+ currentEmotion = e;
1785
+ renderSvg(0);
1786
+ };
1787
+
1788
+ container.setSpeaking = (s) => {
1789
+ isSpeaking = Boolean(s);
1790
+ renderSvg(0);
1791
+ };
1792
+
1793
+ _positionEl(container, x, y);
1794
+ return container;
1795
+ }
1796
+
635
1797
  // ── faceAt — talking-head layout helper ──────────────────────────────────────
636
1798
 
637
1799
  /**
@@ -671,15 +1833,20 @@ export function faceAt(layout, {
671
1833
 
672
1834
  // ── captions — word-level caption sync ───────────────────────────────────────
673
1835
 
1836
+ let _captionOpts = { style: 'viral', highlightColor: '#facc15' };
1837
+
674
1838
  /**
675
- * Register a Whisper outline file for auto-synced captions.
676
- * The outline file is produced by scripts/transcribe.py
1839
+ * Register a Whisper outline file for auto-synced captions with word-level highlighting.
677
1840
  * Format: one JSON object per line: { "start": 0.0, "end": 0.5, "text": "word" }
678
1841
  * or a single JSON array of the same objects.
679
1842
  *
680
1843
  * @param {string} outlinePath Path relative to project root
1844
+ * @param {object} [opts]
1845
+ * @param {'viral'|'cinematic'|'minimal'} [opts.style='viral'] Caption styling preset
1846
+ * @param {string} [opts.highlightColor='#facc15'] Color for active spoken word
681
1847
  */
682
- export function captions(outlinePath) {
1848
+ export function captions(outlinePath, opts = {}) {
1849
+ _captionOpts = { ..._captionOpts, ...opts };
683
1850
  fetch(outlinePath)
684
1851
  .then(r => r.text())
685
1852
  .then(raw => {
@@ -700,33 +1867,129 @@ let _captionEl = null;
700
1867
  function _tickCaptions(t) {
701
1868
  if (!_captionEl) {
702
1869
  _captionEl = document.createElement('div');
1870
+ _captionEl.className = 'reel-captions';
1871
+ const isViral = _captionOpts.style === 'viral';
703
1872
  Object.assign(_captionEl.style, {
704
1873
  position: 'absolute',
705
- bottom: '80px',
1874
+ bottom: isViral ? '120px' : '80px',
706
1875
  left: '50%',
707
1876
  transform: 'translateX(-50%)',
708
- background: 'rgba(0,0,0,0.72)',
1877
+ background: _captionOpts.style === 'minimal' ? 'transparent' : 'rgba(0,0,0,0.76)',
709
1878
  color: '#fff',
710
- fontSize: '32px',
711
- fontWeight: '600',
712
- padding: '10px 28px',
713
- borderRadius: '8px',
714
- maxWidth: '80%',
1879
+ fontSize: isViral ? '36px' : '32px',
1880
+ fontWeight: '800',
1881
+ letterSpacing: isViral ? '0.04em' : 'normal',
1882
+ padding: '12px 32px',
1883
+ borderRadius: '12px',
1884
+ maxWidth: '84%',
715
1885
  textAlign: 'center',
716
1886
  lineHeight: '1.4',
717
1887
  pointerEvents: 'none',
1888
+ boxShadow: _captionOpts.style === 'minimal' ? 'none' : '0 10px 30px rgba(0,0,0,0.5)',
718
1889
  });
719
1890
  document.getElementById('stage').appendChild(_captionEl);
720
1891
  }
721
1892
 
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(' ');
1893
+ // Find active segment
1894
+ const activeSeg = _captionData.find(seg => seg.start <= t && t < seg.end + 0.08);
1895
+
1896
+ if (!activeSeg) {
1897
+ _captionEl.style.display = 'none';
1898
+ return;
1899
+ }
1900
+
1901
+ _captionEl.style.display = '';
1902
+
1903
+ // Tokenize into words
1904
+ let words = activeSeg.words;
1905
+ if (!words || !Array.isArray(words)) {
1906
+ const rawWords = (activeSeg.text || '').trim().split(/\s+/).filter(Boolean);
1907
+ const dur = Math.max(0.1, (activeSeg.end - activeSeg.start) || 1);
1908
+ const wDur = dur / Math.max(1, rawWords.length);
1909
+ words = rawWords.map((w, idx) => ({
1910
+ word: w,
1911
+ start: activeSeg.start + idx * wDur,
1912
+ end: activeSeg.start + (idx + 1) * wDur,
1913
+ }));
1914
+ }
1915
+
1916
+ // Render spans with active word styling
1917
+ const activeColor = _captionOpts.highlightColor || '#facc15';
1918
+ _captionEl.innerHTML = words.map(w => {
1919
+ const isActive = t >= w.start && t < w.end;
1920
+ const isPast = t >= w.end;
1921
+ if (isActive) {
1922
+ return `<span style="color:${activeColor};transform:scale(1.14);display:inline-block;margin:0 4px;font-weight:900;text-shadow:0 0 16px ${activeColor}88">${w.word}</span>`;
1923
+ } else if (isPast) {
1924
+ return `<span style="color:#ffffff;display:inline-block;margin:0 4px;opacity:0.95">${w.word}</span>`;
1925
+ } else {
1926
+ return `<span style="color:#d1d5db;display:inline-block;margin:0 4px;opacity:0.6">${w.word}</span>`;
1927
+ }
1928
+ }).join(' ');
1929
+ }
727
1930
 
728
- _captionEl.textContent = active;
729
- _captionEl.style.display = active ? '' : 'none';
1931
+ // ── Web Audio live procedural SFX synthesis (Leonxlnx/earshot) ───────────────────
1932
+
1933
+ let _audioCtx = null;
1934
+ function _playProceduralSFX(name, vol = 1.0) {
1935
+ if (typeof window === 'undefined' || window.__playwright) return;
1936
+ try {
1937
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
1938
+ if (!AudioCtx) return;
1939
+ if (!_audioCtx) _audioCtx = new AudioCtx();
1940
+ if (_audioCtx.state === 'suspended') _audioCtx.resume();
1941
+
1942
+ const t = _audioCtx.currentTime;
1943
+ const gain = _audioCtx.createGain();
1944
+ gain.connect(_audioCtx.destination);
1945
+ gain.gain.setValueAtTime(vol * 0.3, t);
1946
+
1947
+ if (name === 'whoosh') {
1948
+ const osc = _audioCtx.createOscillator();
1949
+ osc.type = 'sine';
1950
+ osc.frequency.setValueAtTime(400, t);
1951
+ osc.frequency.exponentialRampToValueAtTime(80, t + 0.35);
1952
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
1953
+ osc.connect(gain);
1954
+ osc.start(t);
1955
+ osc.stop(t + 0.35);
1956
+ } else if (name === 'impact') {
1957
+ const osc = _audioCtx.createOscillator();
1958
+ osc.type = 'triangle';
1959
+ osc.frequency.setValueAtTime(120, t);
1960
+ osc.frequency.exponentialRampToValueAtTime(40, t + 0.45);
1961
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
1962
+ osc.connect(gain);
1963
+ osc.start(t);
1964
+ osc.stop(t + 0.45);
1965
+ } else if (name === 'pop') {
1966
+ const osc = _audioCtx.createOscillator();
1967
+ osc.type = 'sine';
1968
+ osc.frequency.setValueAtTime(450, t);
1969
+ osc.frequency.exponentialRampToValueAtTime(850, t + 0.1);
1970
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
1971
+ osc.connect(gain);
1972
+ osc.start(t);
1973
+ osc.stop(t + 0.1);
1974
+ } else if (name === 'click') {
1975
+ const osc = _audioCtx.createOscillator();
1976
+ osc.type = 'square';
1977
+ osc.frequency.setValueAtTime(2400, t);
1978
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04);
1979
+ osc.connect(gain);
1980
+ osc.start(t);
1981
+ osc.stop(t + 0.04);
1982
+ } else if (name === 'riser') {
1983
+ const osc = _audioCtx.createOscillator();
1984
+ osc.type = 'sawtooth';
1985
+ osc.frequency.setValueAtTime(100, t);
1986
+ osc.frequency.exponentialRampToValueAtTime(1200, t + 1.8);
1987
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 1.8);
1988
+ osc.connect(gain);
1989
+ osc.start(t);
1990
+ osc.stop(t + 1.8);
1991
+ }
1992
+ } catch (_) {}
730
1993
  }
731
1994
 
732
1995
  // ── cue — sound effect placement & caption synchronization ───────────────────────
@@ -857,10 +2120,20 @@ function _stylesToCSS(styles) {
857
2120
 
858
2121
  if (typeof window !== 'undefined' && !window.__playwright) {
859
2122
  let _startTime = null;
2123
+ let _prevT = 0;
860
2124
  function _raf(ts) {
861
2125
  if (_startTime === null) _startTime = ts;
862
2126
  const t = Math.min((ts - _startTime) / 1000, REEL.duration);
863
2127
  seek(t);
2128
+ // Live preview procedural audio playback
2129
+ if (_cues.length > 0) {
2130
+ for (const c of _cues) {
2131
+ if (_prevT < c.t && t >= c.t) {
2132
+ _playProceduralSFX(c.sfxName, c.vol);
2133
+ }
2134
+ }
2135
+ }
2136
+ _prevT = t;
864
2137
  if (t < REEL.duration) requestAnimationFrame(_raf);
865
2138
  }
866
2139
  window.addEventListener('load', () => {