@maravilla-labs/frames 0.5.1 → 0.8.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/index.ts CHANGED
@@ -8,6 +8,22 @@
8
8
  * frame to produce deterministic video output.
9
9
  */
10
10
 
11
+ import { AudioEngine, type PreviewAudioTrack } from "./audio.js";
12
+ import { resolveFadeCurve, type FadeCurve } from "./fades.js";
13
+
14
+ export { AudioEngine } from "./audio.js";
15
+ export type { PreviewAudioTrack } from "./audio.js";
16
+ export { FADE_CURVES, fadeAutomation, fadeGain, resolveFadeCurve } from "./fades.js";
17
+ export type { FadeCurve } from "./fades.js";
18
+ export {
19
+ DEFAULT_DUCK,
20
+ duckAutomation,
21
+ duckMultiplier,
22
+ mergeSegments,
23
+ type DuckParams,
24
+ type Segment,
25
+ } from "./ducking.js";
26
+
11
27
  export type AnimationInstr = {
12
28
  /** Optional discriminator. Defaults to `"animation"`. */
13
29
  kind?: "animation";
@@ -67,9 +83,95 @@ export type CssClassInstr = {
67
83
  classes: string[];
68
84
  };
69
85
 
70
- export type Instr = AnimationInstr | VideoInstr | CssClassInstr;
86
+ /** Role of an audio track. `"voiceover"` is what music ducks *under*;
87
+ * `"music"` is the background bed that gets ducked; `"sfx"` is a one-shot
88
+ * that is neither ducked nor a ducking trigger. */
89
+ export type AudioRole = "voiceover" | "music" | "sfx";
90
+
91
+ /**
92
+ * A per-track audio effect. `"radio"` is a band-limited, saturated "deep
93
+ * radio/telephone voice" — applied identically in the browser preview (Web
94
+ * Audio filter chain, [`audio.ts`]) and in the render (ffmpeg, worker-side), so
95
+ * preview matches export. All knobs are 0..1.
96
+ */
97
+ export type AudioEffect = {
98
+ kind: "radio";
99
+ /** Overdrive / saturation amount (0 = clean, 1 = crunchy). Default 0.4. */
100
+ drive?: number;
101
+ /** Band brightness (0 = narrow telephone band, 1 = wide/open). Default 0.4. */
102
+ tone?: number;
103
+ /** Low-end "depth" boost for a deeper voice. Default 0.5. */
104
+ depth?: number;
105
+ };
106
+
107
+ /**
108
+ * An audio track on the timeline. Unlike the visual instructions it owns no
109
+ * DOM element — it's mixed into the final video by the render worker (ffmpeg)
110
+ * and played in the browser preview by the Web Audio engine ([`audio.ts`]).
111
+ * Multiple tracks coexist (e.g. an intro bed + a voiceover); music tracks
112
+ * dip under voiceover via static-envelope ducking (see [`ducking.ts`]).
113
+ */
114
+ export type AudioInstr = {
115
+ /** Discriminator — required. */
116
+ kind: "audio";
117
+ /** When on the timeline the track starts, in milliseconds. */
118
+ at: number;
119
+ /** How long it plays from `at`, in ms. Defaults to the clip's natural length. */
120
+ duration?: number;
121
+ /** Start offset WITHIN the source file, in ms. Defaults to 0. */
122
+ seek?: number;
123
+ /**
124
+ * Playable URL for the browser preview (fetched + decoded via Web Audio).
125
+ * For a stored asset, a URL that resolves to it (e.g. `/_assets/<key>`).
126
+ */
127
+ src: string;
128
+ /**
129
+ * Storage key the render worker fetches + stages to mix into the final
130
+ * video. Without it the track is preview-only (it can't be rendered).
131
+ */
132
+ key?: string;
133
+ /** Track role. Defaults to `"music"`. */
134
+ role?: AudioRole;
135
+ /** Linear gain (1 = unity). Defaults to 1. */
136
+ gain?: number;
137
+ /** Fade-in / fade-out length from the clip edges, ms. Default 0. */
138
+ fadeIn?: number;
139
+ fadeOut?: number;
140
+ /**
141
+ * Shape of each fade — `linear` | `equal-power` | `s-curve` | `exponential`
142
+ * | `logarithmic` (ffmpeg `afade` curves; see `fades.ts`). Default `linear`.
143
+ */
144
+ fadeInCurve?: FadeCurve;
145
+ fadeOutCurve?: FadeCurve;
146
+ /** Whether a music track ducks under voiceover. Defaults to `role === "music"`. */
147
+ duck?: boolean;
148
+ /** Optional per-track effect (e.g. a "radio voice"). */
149
+ effect?: AudioEffect;
150
+ };
151
+
152
+ export type Instr = AnimationInstr | VideoInstr | CssClassInstr | AudioInstr;
71
153
  export type TimelineSchema = { duration: number; instructions: Instr[] };
72
154
 
155
+ /** An audio track as exposed to the render worker via `window.__mvFrames.audio`.
156
+ * The worker stages `key` and builds the ffmpeg mix; `duration === null`
157
+ * means "use the source's natural length". Mirrors [`AudioInstr`] with
158
+ * defaults resolved. */
159
+ export type AudioSlotInfo = {
160
+ /** Storage key the worker stages (absent ⇒ preview-only, not rendered). */
161
+ key?: string;
162
+ at: number;
163
+ duration: number | null;
164
+ seek: number;
165
+ gain: number;
166
+ role: AudioRole;
167
+ duck: boolean;
168
+ fadeIn: number;
169
+ fadeOut: number;
170
+ fadeInCurve: FadeCurve;
171
+ fadeOutCurve: FadeCurve;
172
+ effect?: AudioEffect;
173
+ };
174
+
73
175
  /** A `<video>` slot as exposed to the renderer engine (worker). The engine
74
176
  * pre-extracts each clip to frames with ffmpeg and swaps the live `<video>`
75
177
  * for the matching frame image per captured frame — so the live video is never
@@ -96,6 +198,17 @@ declare global {
96
198
  /** Video slots in this timeline — read by the renderer engine to
97
199
  * swap video frames out-of-band. Empty when no video. */
98
200
  videos: VideoSlotInfo[];
201
+ /** Audio tracks in this timeline — read by the render worker to build
202
+ * the ffmpeg mix. Empty when no audio. */
203
+ audio: AudioSlotInfo[];
204
+ /** Preview transport (browser only; no-ops under the renderer). Start
205
+ * the audio mix from timeline position `fromMs`. The preview host calls
206
+ * this on play; scrubbing via `applyState` stays silent. */
207
+ playAudio(fromMs: number): void;
208
+ /** Stop preview audio playback. */
209
+ stopAudio(): void;
210
+ /** Mute / unmute preview audio without tearing down playback. */
211
+ setAudioMuted(muted: boolean): void;
99
212
  };
100
213
  /** Set true by the headless renderer when it captures (`__mvFramesRendererPresent`). */
101
214
  __mvFramesRendererPresent?: boolean;
@@ -244,7 +357,18 @@ function installTimeline(schema: TimelineSchema): void {
244
357
  const rendererPresent =
245
358
  typeof window !== "undefined" && window.__mvFramesRendererPresent === true;
246
359
 
247
- const slots: Slot[] = schema.instructions.flatMap<Slot>((i) => {
360
+ // Audio tracks own no DOM element — split them out before the visual
361
+ // instructions hit `querySelector`. They're mixed by the worker (which
362
+ // reads `__mvFrames.audio`) and previewed by the Web Audio engine below.
363
+ const audioInstrs = schema.instructions.filter(
364
+ (i): i is AudioInstr => (i as { kind?: string }).kind === "audio",
365
+ );
366
+ const visualInstrs = schema.instructions.filter(
367
+ (i): i is Exclude<Instr, AudioInstr> =>
368
+ (i as { kind?: string }).kind !== "audio",
369
+ );
370
+
371
+ const slots: Slot[] = visualInstrs.flatMap<Slot>((i) => {
248
372
  const el = document.querySelector(i.selector);
249
373
  if (!el) throw new Error(`defineTimeline: no element matches ${i.selector}`);
250
374
 
@@ -347,8 +471,72 @@ function installTimeline(schema: TimelineSchema): void {
347
471
  frameset: s.frameset,
348
472
  }));
349
473
 
474
+ // Resolve audio instruction defaults once — shared by the worker-facing
475
+ // slot list and the browser preview engine so both see the same mix.
476
+ const audioResolved = audioInstrs.map((a) => {
477
+ const role: AudioRole = a.role ?? "music";
478
+ return {
479
+ src: a.src,
480
+ key: a.key,
481
+ at: a.at,
482
+ duration: a.duration ?? null,
483
+ seek: a.seek ?? 0,
484
+ gain: a.gain ?? 1,
485
+ role,
486
+ duck: a.duck ?? role === "music",
487
+ fadeIn: a.fadeIn ?? 0,
488
+ fadeOut: a.fadeOut ?? 0,
489
+ fadeInCurve: resolveFadeCurve(a.fadeInCurve),
490
+ fadeOutCurve: resolveFadeCurve(a.fadeOutCurve),
491
+ effect: a.effect,
492
+ };
493
+ });
494
+
495
+ // Worker-facing slots (read via CDP to build the ffmpeg mix). Drop tracks
496
+ // with no storage key — those are preview-only and can't be rendered.
497
+ const audio: AudioSlotInfo[] = audioResolved
498
+ .filter((a) => typeof a.key === "string" && a.key.length > 0)
499
+ .map(({ src: _src, ...slot }) => slot);
500
+
501
+ // Browser preview engine. Constructed whenever there are tracks AND we're in
502
+ // a real browser — INCLUDING under `__mvFramesRendererPresent` (the Studio
503
+ // preview iframe sets that flag so it can drive `applyState`, yet still wants
504
+ // audible playback). It's lazy — no AudioContext, no fetch until `playFrom`
505
+ // is called — so in the *headless* renderer (which only ever calls
506
+ // `applyState`, never `playAudio`) it stays completely inert and the real mix
507
+ // is done by ffmpeg. Construction is just field storage.
508
+ const audioEngine =
509
+ typeof window !== "undefined" && typeof AudioContext !== "undefined" && audioResolved.length > 0
510
+ ? new AudioEngine(
511
+ audioResolved.map<PreviewAudioTrack>((a) => ({
512
+ src: a.src,
513
+ at: a.at,
514
+ duration: a.duration,
515
+ seek: a.seek,
516
+ gain: a.gain,
517
+ fadeIn: a.fadeIn,
518
+ fadeOut: a.fadeOut,
519
+ fadeInCurve: a.fadeInCurve,
520
+ fadeOutCurve: a.fadeOutCurve,
521
+ role: a.role,
522
+ duck: a.duck,
523
+ effect: a.effect,
524
+ })),
525
+ )
526
+ : null;
527
+
350
528
  window.__mvFrames = {
351
529
  videos,
530
+ audio,
531
+ playAudio(fromMs: number) {
532
+ void audioEngine?.playFrom(fromMs);
533
+ },
534
+ stopAudio() {
535
+ audioEngine?.stop();
536
+ },
537
+ setAudioMuted(muted: boolean) {
538
+ audioEngine?.setMuted(muted);
539
+ },
352
540
  async applyState(t: number) {
353
541
  const videoWaits: Promise<void>[] = [];
354
542
  for (const s of slots) {