@amg555/frameforge-mcp 2.4.10 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +22 -4
- package/CHANGELOG.md +174 -0
- package/CLAUDE.md +192 -9
- package/README.md +47 -6
- package/output/harness_test_1794418727139/frames/f000000.jpg +1 -0
- package/package.json +4 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
- package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
- package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
- package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
- package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
- package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
- package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
- package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
- package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
- package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
- package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
- package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
- package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
- package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
- package/scripts/deep-validate.js +52 -2
- package/src/audio-mixer/mixer.js +594 -63
- package/src/cli/doctor.js +36 -2
- package/src/cli/make.js +113 -9
- package/src/harness/bin/harness.mjs +127 -5
- package/src/harness/lib/capture.mjs +215 -192
- package/src/harness/lib/verify-quality.mjs +47 -7
- package/src/mcp-server/server.js +1015 -59
- package/src/skills/sync-upstream.js +290 -0
- package/src/utils/media-downloader.js +162 -0
- package/src/utils/transcribe.js +341 -0
- package/src/video-editor/editor.js +96 -11
- package/src/workflows/ingest.js +10 -0
|
@@ -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
|
-
//
|
|
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.
|
|
1877
|
+
background: _captionOpts.style === 'minimal' ? 'transparent' : 'rgba(0,0,0,0.76)',
|
|
709
1878
|
color: '#fff',
|
|
710
|
-
fontSize: '32px',
|
|
711
|
-
fontWeight: '
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
//
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
.
|
|
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
|
-
|
|
729
|
-
|
|
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', () => {
|