@maravilla-labs/frames 0.5.0 → 0.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/src/ducking.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Static-envelope ducking — the single source of truth for "music dips
3
+ * under voiceover" shared by the browser preview ([`audio.ts`]) and the
4
+ * Rust render worker (which mirrors this math when it builds the ffmpeg
5
+ * `volume` expression).
6
+ *
7
+ * The model is deliberately *static*: the dip is computed purely from the
8
+ * voiceover clips' timeline positions (`at` / `duration`), NOT from the
9
+ * voiceover's actual loudness. That makes it deterministic — the same
10
+ * timeline produces the same envelope every render — and, crucially, lets
11
+ * the browser preview reproduce the exact gain curve the final mp4 will
12
+ * have (a sidechain compressor could not be mirrored frame-for-frame in
13
+ * Web Audio).
14
+ *
15
+ * All times here are in **seconds** (Web Audio + ffmpeg both want seconds);
16
+ * the DSL stores milliseconds, so callers convert at the boundary.
17
+ */
18
+
19
+ /** Tunable ducking parameters. Defaults match the shorts editor. */
20
+ export type DuckParams = {
21
+ /** Music multiplier while voiceover is active. `0.25` ≈ −12 dB. */
22
+ level: number;
23
+ /** Linear ramp into / out of the dip, seconds. */
24
+ ramp: number;
25
+ };
26
+
27
+ export const DEFAULT_DUCK: DuckParams = { level: 0.25, ramp: 0.25 };
28
+
29
+ /** A half-open `[start, end)` interval on the timeline, seconds. */
30
+ export type Segment = { start: number; end: number };
31
+
32
+ /**
33
+ * Merge overlapping / touching voiceover segments into a disjoint, sorted
34
+ * set. Merging is what makes the preview automation and the ffmpeg
35
+ * expression agree even when voiceover clips overlap: a union of regions
36
+ * is unambiguous, whereas per-clip ramps could otherwise fight each other.
37
+ */
38
+ export function mergeSegments(segments: Segment[]): Segment[] {
39
+ const sorted = segments
40
+ .filter((s) => s.end > s.start)
41
+ .sort((a, b) => a.start - b.start);
42
+ const out: Segment[] = [];
43
+ for (const s of sorted) {
44
+ const last = out[out.length - 1];
45
+ if (last && s.start <= last.end) {
46
+ last.end = Math.max(last.end, s.end);
47
+ } else {
48
+ out.push({ ...s });
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * The ducking multiplier at time `t` (seconds) for a music track, given the
56
+ * merged voiceover `segments`. `1` = full volume, `level` = fully ducked,
57
+ * with linear ramps of width `ramp` on each edge. Used by the preview to
58
+ * sample the curve and by tests to assert the Rust expression matches.
59
+ */
60
+ export function duckMultiplier(
61
+ t: number,
62
+ segments: Segment[],
63
+ duck: DuckParams = DEFAULT_DUCK,
64
+ ): number {
65
+ let m = 1;
66
+ for (const { start, end } of segments) {
67
+ const seg = segMultiplier(t, start, end, duck);
68
+ if (seg < m) m = seg;
69
+ }
70
+ return m;
71
+ }
72
+
73
+ function segMultiplier(t: number, s: number, e: number, duck: DuckParams): number {
74
+ const { level: d, ramp: r } = duck;
75
+ if (t <= s - r || t >= e + r) return 1;
76
+ if (t < s) return 1 - (1 - d) * (t - (s - r)) / r; // ramp down
77
+ if (t <= e) return d; // hold
78
+ return d + (1 - d) * (t - e) / r; // ramp up
79
+ }
80
+
81
+ /**
82
+ * Gain-automation breakpoints `[time, value]` for a music track's
83
+ * `GainNode`, scaled by `base` gain. The preview replays these with
84
+ * `setValueAtTime` / `linearRampToValueAtTime` so the dip in the player
85
+ * matches the dip baked into the render. Times are absolute timeline
86
+ * seconds.
87
+ */
88
+ export function duckAutomation(
89
+ segments: Segment[],
90
+ base: number,
91
+ duck: DuckParams = DEFAULT_DUCK,
92
+ ): Array<[number, number]> {
93
+ const merged = mergeSegments(segments);
94
+ const points: Array<[number, number]> = [[0, base]];
95
+ const { level: d, ramp: r } = duck;
96
+ for (const { start, end } of merged) {
97
+ points.push([Math.max(0, start - r), base]); // begin ramp down
98
+ points.push([start, base * d]); // fully ducked
99
+ points.push([end, base * d]); // hold to clip end
100
+ points.push([end + r, base]); // ramp back up
101
+ }
102
+ return points;
103
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,19 @@
8
8
  * frame to produce deterministic video output.
9
9
  */
10
10
 
11
+ import { AudioEngine, type PreviewAudioTrack } from "./audio.js";
12
+
13
+ export { AudioEngine } from "./audio.js";
14
+ export type { PreviewAudioTrack } from "./audio.js";
15
+ export {
16
+ DEFAULT_DUCK,
17
+ duckAutomation,
18
+ duckMultiplier,
19
+ mergeSegments,
20
+ type DuckParams,
21
+ type Segment,
22
+ } from "./ducking.js";
23
+
11
24
  export type AnimationInstr = {
12
25
  /** Optional discriminator. Defaults to `"animation"`. */
13
26
  kind?: "animation";
@@ -67,9 +80,87 @@ export type CssClassInstr = {
67
80
  classes: string[];
68
81
  };
69
82
 
70
- export type Instr = AnimationInstr | VideoInstr | CssClassInstr;
83
+ /** Role of an audio track. `"voiceover"` is what music ducks *under*;
84
+ * `"music"` is the background bed that gets ducked; `"sfx"` is a one-shot
85
+ * that is neither ducked nor a ducking trigger. */
86
+ export type AudioRole = "voiceover" | "music" | "sfx";
87
+
88
+ /**
89
+ * A per-track audio effect. `"radio"` is a band-limited, saturated "deep
90
+ * radio/telephone voice" — applied identically in the browser preview (Web
91
+ * Audio filter chain, [`audio.ts`]) and in the render (ffmpeg, worker-side), so
92
+ * preview matches export. All knobs are 0..1.
93
+ */
94
+ export type AudioEffect = {
95
+ kind: "radio";
96
+ /** Overdrive / saturation amount (0 = clean, 1 = crunchy). Default 0.4. */
97
+ drive?: number;
98
+ /** Band brightness (0 = narrow telephone band, 1 = wide/open). Default 0.4. */
99
+ tone?: number;
100
+ /** Low-end "depth" boost for a deeper voice. Default 0.5. */
101
+ depth?: number;
102
+ };
103
+
104
+ /**
105
+ * An audio track on the timeline. Unlike the visual instructions it owns no
106
+ * DOM element — it's mixed into the final video by the render worker (ffmpeg)
107
+ * and played in the browser preview by the Web Audio engine ([`audio.ts`]).
108
+ * Multiple tracks coexist (e.g. an intro bed + a voiceover); music tracks
109
+ * dip under voiceover via static-envelope ducking (see [`ducking.ts`]).
110
+ */
111
+ export type AudioInstr = {
112
+ /** Discriminator — required. */
113
+ kind: "audio";
114
+ /** When on the timeline the track starts, in milliseconds. */
115
+ at: number;
116
+ /** How long it plays from `at`, in ms. Defaults to the clip's natural length. */
117
+ duration?: number;
118
+ /** Start offset WITHIN the source file, in ms. Defaults to 0. */
119
+ seek?: number;
120
+ /**
121
+ * Playable URL for the browser preview (fetched + decoded via Web Audio).
122
+ * For a stored asset, a URL that resolves to it (e.g. `/_assets/<key>`).
123
+ */
124
+ src: string;
125
+ /**
126
+ * Storage key the render worker fetches + stages to mix into the final
127
+ * video. Without it the track is preview-only (it can't be rendered).
128
+ */
129
+ key?: string;
130
+ /** Track role. Defaults to `"music"`. */
131
+ role?: AudioRole;
132
+ /** Linear gain (1 = unity). Defaults to 1. */
133
+ gain?: number;
134
+ /** Fade-in / fade-out length from the clip edges, ms. Default 0. */
135
+ fadeIn?: number;
136
+ fadeOut?: number;
137
+ /** Whether a music track ducks under voiceover. Defaults to `role === "music"`. */
138
+ duck?: boolean;
139
+ /** Optional per-track effect (e.g. a "radio voice"). */
140
+ effect?: AudioEffect;
141
+ };
142
+
143
+ export type Instr = AnimationInstr | VideoInstr | CssClassInstr | AudioInstr;
71
144
  export type TimelineSchema = { duration: number; instructions: Instr[] };
72
145
 
146
+ /** An audio track as exposed to the render worker via `window.__mvFrames.audio`.
147
+ * The worker stages `key` and builds the ffmpeg mix; `duration === null`
148
+ * means "use the source's natural length". Mirrors [`AudioInstr`] with
149
+ * defaults resolved. */
150
+ export type AudioSlotInfo = {
151
+ /** Storage key the worker stages (absent ⇒ preview-only, not rendered). */
152
+ key?: string;
153
+ at: number;
154
+ duration: number | null;
155
+ seek: number;
156
+ gain: number;
157
+ role: AudioRole;
158
+ duck: boolean;
159
+ fadeIn: number;
160
+ fadeOut: number;
161
+ effect?: AudioEffect;
162
+ };
163
+
73
164
  /** A `<video>` slot as exposed to the renderer engine (worker). The engine
74
165
  * pre-extracts each clip to frames with ffmpeg and swaps the live `<video>`
75
166
  * for the matching frame image per captured frame — so the live video is never
@@ -96,6 +187,17 @@ declare global {
96
187
  /** Video slots in this timeline — read by the renderer engine to
97
188
  * swap video frames out-of-band. Empty when no video. */
98
189
  videos: VideoSlotInfo[];
190
+ /** Audio tracks in this timeline — read by the render worker to build
191
+ * the ffmpeg mix. Empty when no audio. */
192
+ audio: AudioSlotInfo[];
193
+ /** Preview transport (browser only; no-ops under the renderer). Start
194
+ * the audio mix from timeline position `fromMs`. The preview host calls
195
+ * this on play; scrubbing via `applyState` stays silent. */
196
+ playAudio(fromMs: number): void;
197
+ /** Stop preview audio playback. */
198
+ stopAudio(): void;
199
+ /** Mute / unmute preview audio without tearing down playback. */
200
+ setAudioMuted(muted: boolean): void;
99
201
  };
100
202
  /** Set true by the headless renderer when it captures (`__mvFramesRendererPresent`). */
101
203
  __mvFramesRendererPresent?: boolean;
@@ -189,11 +291,73 @@ export function stylesheetsReady(): Promise<void> {
189
291
  .then(() => (document.fonts ? document.fonts.ready.then(() => undefined) : undefined));
190
292
  }
191
293
 
294
+ /**
295
+ * Seek `el` to `targetSec` and resolve only once the seeked frame has
296
+ * actually decoded — so a subsequent capture grabs the right frame instead
297
+ * of racing an in-flight seek.
298
+ *
299
+ * Resolution waits for the `seeked` event, then for one decoded video frame
300
+ * via `requestVideoFrameCallback` where available (fallback: a microtask, so
301
+ * we still yield to the decode). A timeout guards against `seeked` never
302
+ * firing (e.g. seeking to the exact current position, or an unsupported
303
+ * codec) so `applyState` can't hang the whole render. Already-satisfied
304
+ * seeks (`readyState` high enough and time already at target) resolve fast.
305
+ */
306
+ function seekVideo(el: HTMLVideoElement, targetSec: number): Promise<void> {
307
+ const HAVE_CURRENT_DATA = 2;
308
+ // If we're already at (or within a frame of) the target with data ready,
309
+ // there's nothing to wait for — assigning currentTime won't fire `seeked`.
310
+ const alreadyThere =
311
+ Math.abs(el.currentTime - targetSec) < 1e-3 && el.readyState >= HAVE_CURRENT_DATA;
312
+ el.currentTime = targetSec;
313
+ if (alreadyThere) return Promise.resolve();
314
+
315
+ return new Promise<void>((resolve) => {
316
+ let done = false;
317
+ const finish = () => {
318
+ if (done) return;
319
+ done = true;
320
+ el.removeEventListener("seeked", onSeeked);
321
+ clearTimeout(timer);
322
+ resolve();
323
+ };
324
+ const afterSeeked = () => {
325
+ // Wait for one decoded frame to be presented when the API exists.
326
+ const rvfc = (
327
+ el as unknown as {
328
+ requestVideoFrameCallback?: (cb: () => void) => number;
329
+ }
330
+ ).requestVideoFrameCallback;
331
+ if (typeof rvfc === "function") {
332
+ rvfc.call(el, () => finish());
333
+ } else {
334
+ // No rVFC: yield a microtask so the decode can settle, then finish.
335
+ Promise.resolve().then(finish);
336
+ }
337
+ };
338
+ const onSeeked = () => afterSeeked();
339
+ el.addEventListener("seeked", onSeeked, { once: true });
340
+ // Guard: never let a missing `seeked` stall the render.
341
+ const timer = setTimeout(finish, 2000);
342
+ });
343
+ }
344
+
192
345
  function installTimeline(schema: TimelineSchema): void {
193
346
  const rendererPresent =
194
347
  typeof window !== "undefined" && window.__mvFramesRendererPresent === true;
195
348
 
196
- const slots: Slot[] = schema.instructions.flatMap<Slot>((i) => {
349
+ // Audio tracks own no DOM element — split them out before the visual
350
+ // instructions hit `querySelector`. They're mixed by the worker (which
351
+ // reads `__mvFrames.audio`) and previewed by the Web Audio engine below.
352
+ const audioInstrs = schema.instructions.filter(
353
+ (i): i is AudioInstr => (i as { kind?: string }).kind === "audio",
354
+ );
355
+ const visualInstrs = schema.instructions.filter(
356
+ (i): i is Exclude<Instr, AudioInstr> =>
357
+ (i as { kind?: string }).kind !== "audio",
358
+ );
359
+
360
+ const slots: Slot[] = visualInstrs.flatMap<Slot>((i) => {
197
361
  const el = document.querySelector(i.selector);
198
362
  if (!el) throw new Error(`defineTimeline: no element matches ${i.selector}`);
199
363
 
@@ -296,9 +460,70 @@ function installTimeline(schema: TimelineSchema): void {
296
460
  frameset: s.frameset,
297
461
  }));
298
462
 
463
+ // Resolve audio instruction defaults once — shared by the worker-facing
464
+ // slot list and the browser preview engine so both see the same mix.
465
+ const audioResolved = audioInstrs.map((a) => {
466
+ const role: AudioRole = a.role ?? "music";
467
+ return {
468
+ src: a.src,
469
+ key: a.key,
470
+ at: a.at,
471
+ duration: a.duration ?? null,
472
+ seek: a.seek ?? 0,
473
+ gain: a.gain ?? 1,
474
+ role,
475
+ duck: a.duck ?? role === "music",
476
+ fadeIn: a.fadeIn ?? 0,
477
+ fadeOut: a.fadeOut ?? 0,
478
+ effect: a.effect,
479
+ };
480
+ });
481
+
482
+ // Worker-facing slots (read via CDP to build the ffmpeg mix). Drop tracks
483
+ // with no storage key — those are preview-only and can't be rendered.
484
+ const audio: AudioSlotInfo[] = audioResolved
485
+ .filter((a) => typeof a.key === "string" && a.key.length > 0)
486
+ .map(({ src: _src, ...slot }) => slot);
487
+
488
+ // Browser preview engine. Constructed whenever there are tracks AND we're in
489
+ // a real browser — INCLUDING under `__mvFramesRendererPresent` (the Studio
490
+ // preview iframe sets that flag so it can drive `applyState`, yet still wants
491
+ // audible playback). It's lazy — no AudioContext, no fetch until `playFrom`
492
+ // is called — so in the *headless* renderer (which only ever calls
493
+ // `applyState`, never `playAudio`) it stays completely inert and the real mix
494
+ // is done by ffmpeg. Construction is just field storage.
495
+ const audioEngine =
496
+ typeof window !== "undefined" && typeof AudioContext !== "undefined" && audioResolved.length > 0
497
+ ? new AudioEngine(
498
+ audioResolved.map<PreviewAudioTrack>((a) => ({
499
+ src: a.src,
500
+ at: a.at,
501
+ duration: a.duration,
502
+ seek: a.seek,
503
+ gain: a.gain,
504
+ fadeIn: a.fadeIn,
505
+ fadeOut: a.fadeOut,
506
+ role: a.role,
507
+ duck: a.duck,
508
+ effect: a.effect,
509
+ })),
510
+ )
511
+ : null;
512
+
299
513
  window.__mvFrames = {
300
514
  videos,
515
+ audio,
516
+ playAudio(fromMs: number) {
517
+ void audioEngine?.playFrom(fromMs);
518
+ },
519
+ stopAudio() {
520
+ audioEngine?.stop();
521
+ },
522
+ setAudioMuted(muted: boolean) {
523
+ audioEngine?.setMuted(muted);
524
+ },
301
525
  async applyState(t: number) {
526
+ const videoWaits: Promise<void>[] = [];
302
527
  for (const s of slots) {
303
528
  if (s.kind === "css-pending") continue; // only relevant in browser preview
304
529
  if (s.kind === "video") {
@@ -306,18 +531,24 @@ function installTimeline(schema: TimelineSchema): void {
306
531
  // Seek the live <video> EXCEPT on the renderer's begin-frame-control
307
532
  // capture path (where seeking crashes the headless tab — the engine
308
533
  // swaps in pre-extracted frame images instead, flagged via
309
- // `__mvFramesNoVideoSeek`). So the editor, live preview, AND the
310
- // renderer's screenshot path all seek normally — frame-accurate
311
- // "time travel" (StagePreview drives this via applyState).
534
+ // `__mvFramesNoVideoSeek`). On every live-seek path (editor, live
535
+ // preview, AND the renderer's screenshot capture) we must AWAIT the
536
+ // seeked frame's decode before resolving otherwise a capture races
537
+ // an in-flight seek and grabs a stale/blank frame (and the per-frame
538
+ // loop spins, hitting the render wall-clock budget). `seekVideo`
539
+ // guards with a timeout so a missing `seeked` can't hang the render.
312
540
  if (!window.__mvFramesNoVideoSeek) {
313
- s.el.currentTime = (s.seek + local) / 1000;
541
+ videoWaits.push(seekVideo(s.el, (s.seek + local) / 1000));
314
542
  }
315
543
  continue;
316
544
  }
317
545
  const local = clamp(t - s.at, 0, s.duration);
318
546
  s.anim.currentTime = local;
319
547
  }
320
- await document.fonts.ready;
548
+ await Promise.all([
549
+ document.fonts ? document.fonts.ready.then(() => undefined) : Promise.resolve(),
550
+ ...videoWaits,
551
+ ]);
321
552
  },
322
553
  };
323
554