@scenar/core 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/index.d.ts +17 -1
  2. package/index.d.ts.map +1 -1
  3. package/index.js +9 -1
  4. package/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/presenter/derive-presenter-timeline.d.ts +36 -0
  7. package/presenter/derive-presenter-timeline.d.ts.map +1 -0
  8. package/presenter/derive-presenter-timeline.js +31 -0
  9. package/presenter/derive-presenter-timeline.js.map +1 -0
  10. package/presenter/presenter-opacity.d.ts +32 -0
  11. package/presenter/presenter-opacity.d.ts.map +1 -0
  12. package/presenter/presenter-opacity.js +40 -0
  13. package/presenter/presenter-opacity.js.map +1 -0
  14. package/presenter/types.d.ts +45 -0
  15. package/presenter/types.d.ts.map +1 -0
  16. package/presenter/types.js +16 -0
  17. package/presenter/types.js.map +1 -0
  18. package/scenario/apply-title-cards.d.ts +53 -0
  19. package/scenario/apply-title-cards.d.ts.map +1 -0
  20. package/scenario/apply-title-cards.js +113 -0
  21. package/scenario/apply-title-cards.js.map +1 -0
  22. package/scenario/bundle.d.ts +15 -0
  23. package/scenario/bundle.d.ts.map +1 -1
  24. package/scenario/soundtrack.d.ts +46 -0
  25. package/scenario/soundtrack.d.ts.map +1 -0
  26. package/scenario/soundtrack.js +11 -0
  27. package/scenario/soundtrack.js.map +1 -0
  28. package/scenario/title-cards.d.ts +64 -0
  29. package/scenario/title-cards.d.ts.map +1 -0
  30. package/scenario/title-cards.js +20 -0
  31. package/scenario/title-cards.js.map +1 -0
  32. package/scenario/types.d.ts +36 -2
  33. package/scenario/types.d.ts.map +1 -1
  34. package/src/index.ts +32 -1
  35. package/src/presenter/derive-presenter-timeline.test.ts +138 -0
  36. package/src/presenter/derive-presenter-timeline.ts +54 -0
  37. package/src/presenter/presenter-opacity.test.ts +47 -0
  38. package/src/presenter/presenter-opacity.ts +43 -0
  39. package/src/presenter/types.ts +46 -0
  40. package/src/scenario/apply-title-cards.test.ts +268 -0
  41. package/src/scenario/apply-title-cards.ts +141 -0
  42. package/src/scenario/bundle.ts +15 -0
  43. package/src/scenario/soundtrack.ts +47 -0
  44. package/src/scenario/title-cards.ts +67 -0
  45. package/src/scenario/types.ts +36 -2
  46. package/src/timeline/compute-step-timeline.test.ts +21 -0
  47. package/src/timeline/compute-step-timeline.ts +30 -7
  48. package/src/timeline/derive-action-events.test.ts +144 -0
  49. package/src/timeline/derive-action-events.ts +116 -0
  50. package/src/timeline/derive-sfx-timeline.test.ts +114 -0
  51. package/src/timeline/derive-sfx-timeline.ts +79 -0
  52. package/src/timeline/music-envelope.test.ts +141 -0
  53. package/src/timeline/music-envelope.ts +131 -0
  54. package/timeline/compute-step-timeline.d.ts +28 -3
  55. package/timeline/compute-step-timeline.d.ts.map +1 -1
  56. package/timeline/compute-step-timeline.js +11 -3
  57. package/timeline/compute-step-timeline.js.map +1 -1
  58. package/timeline/derive-action-events.d.ts +40 -0
  59. package/timeline/derive-action-events.d.ts.map +1 -0
  60. package/timeline/derive-action-events.js +71 -0
  61. package/timeline/derive-action-events.js.map +1 -0
  62. package/timeline/derive-sfx-timeline.d.ts +36 -0
  63. package/timeline/derive-sfx-timeline.d.ts.map +1 -0
  64. package/timeline/derive-sfx-timeline.js +49 -0
  65. package/timeline/derive-sfx-timeline.js.map +1 -0
  66. package/timeline/music-envelope.d.ts +69 -0
  67. package/timeline/music-envelope.d.ts.map +1 -0
  68. package/timeline/music-envelope.js +95 -0
  69. package/timeline/music-envelope.js.map +1 -0
@@ -0,0 +1,131 @@
1
+ import type { NarrationManifest } from "../narration/types.js";
2
+ import {
3
+ DUCKING_VOLUME_DEFAULT,
4
+ MUSIC_VOLUME_DEFAULT,
5
+ type Soundtrack,
6
+ } from "../scenario/soundtrack.js";
7
+ import { computeStepTimeline } from "./compute-step-timeline.js";
8
+
9
+ /**
10
+ * Milliseconds over which the music fades in from silence at scenario
11
+ * start. Long enough to feel intentional, short enough that the opening
12
+ * step is never dry.
13
+ */
14
+ export const MUSIC_FADE_IN_MS = 1_000;
15
+
16
+ /**
17
+ * Milliseconds over which the music fades out at the end of the
18
+ * scenario. Matches the final-dwell window the timeline already reserves
19
+ * after the last step, so the video never ends on a hard musical cut.
20
+ */
21
+ export const MUSIC_FADE_OUT_MS = 3_000;
22
+
23
+ /**
24
+ * Milliseconds the music takes to ramp between its base level and its
25
+ * ducked level at each narration boundary. The ramp completes as the
26
+ * voice starts (pre-duck) so narration onset is never fighting the ramp;
27
+ * felt, not heard as a cut.
28
+ */
29
+ export const DUCKING_RAMP_MS = 300;
30
+
31
+ /** A span of the scenario timeline during which a narration clip plays. */
32
+ export interface DuckingWindow {
33
+ readonly startMs: number;
34
+ readonly endMs: number;
35
+ }
36
+
37
+ /**
38
+ * Everything needed to compute the music level at any point on the
39
+ * scenario timeline. Precomputed once per scenario; `musicGainAt` reads
40
+ * it per sample/frame without allocating.
41
+ */
42
+ export interface MusicEnvelope {
43
+ /** Resolved base music level (0–1). */
44
+ readonly musicVolume: number;
45
+ /** Resolved ducked music level while narration plays (0–1). */
46
+ readonly duckingVolume: number;
47
+ /** Total scenario duration, from `computeStepTimeline`. */
48
+ readonly totalDurationMs: number;
49
+ /** Narration clip spans on the timeline, in step order. */
50
+ readonly duckingWindows: readonly DuckingWindow[];
51
+ }
52
+
53
+ /**
54
+ * Precompute the music envelope for a scenario: resolved volume levels
55
+ * plus the narration windows the music ducks under.
56
+ *
57
+ * The windows derive from the same `computeStepTimeline` both output
58
+ * paths already share — a clip for step N spans from that step's start
59
+ * for the clip's duration — so ducking agrees with narration placement
60
+ * by construction, in browser playback and video export alike.
61
+ */
62
+ export function computeMusicEnvelope(
63
+ steps: readonly { delayMs: number }[],
64
+ manifest: NarrationManifest | null | undefined,
65
+ soundtrack: Soundtrack,
66
+ ): MusicEnvelope {
67
+ const { stepStartTimesMs, totalDurationMs } = computeStepTimeline(steps, manifest);
68
+
69
+ const duckingWindows: DuckingWindow[] = [];
70
+ if (manifest) {
71
+ for (let i = 0; i < steps.length; i++) {
72
+ const durationMs = manifest.steps[i]?.durationMs ?? 0;
73
+ if (durationMs > 0) {
74
+ const startMs = stepStartTimesMs[i] ?? 0;
75
+ duckingWindows.push({ startMs, endMs: startMs + durationMs });
76
+ }
77
+ }
78
+ }
79
+
80
+ return {
81
+ musicVolume: soundtrack.musicVolume ?? MUSIC_VOLUME_DEFAULT,
82
+ duckingVolume: soundtrack.duckingVolume ?? DUCKING_VOLUME_DEFAULT,
83
+ totalDurationMs,
84
+ duckingWindows,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The music level at a point on the scenario timeline (0–1).
90
+ *
91
+ * Composed of three factors:
92
+ * - a fade-in from silence over {@link MUSIC_FADE_IN_MS} at the start,
93
+ * - a fade-out to silence over {@link MUSIC_FADE_OUT_MS} at the end,
94
+ * - narration ducking: at the base level away from narration, at the
95
+ * ducked level while a clip plays, ramping over {@link DUCKING_RAMP_MS}
96
+ * into each window (completing at voice onset) and out after it ends.
97
+ *
98
+ * Pure and allocation-free: the browser applies it through Web Audio
99
+ * gain automation, video export as a per-frame volume function. Outside
100
+ * the scenario ([0, totalDurationMs]) the level is 0.
101
+ */
102
+ export function musicGainAt(envelope: MusicEnvelope, timeMs: number): number {
103
+ const { musicVolume, duckingVolume, totalDurationMs, duckingWindows } = envelope;
104
+ if (timeMs < 0 || timeMs > totalDurationMs) return 0;
105
+
106
+ // Ducked-ness in [0, 1]: 0 at the base level, 1 fully ducked. Overlapping
107
+ // windows take the deepest value.
108
+ let ducked = 0;
109
+ for (const window of duckingWindows) {
110
+ const rampInStart = window.startMs - DUCKING_RAMP_MS;
111
+ if (timeMs < rampInStart || timeMs > window.endMs + DUCKING_RAMP_MS) continue;
112
+
113
+ let d: number;
114
+ if (timeMs < window.startMs) {
115
+ d = (timeMs - rampInStart) / DUCKING_RAMP_MS;
116
+ } else if (timeMs <= window.endMs) {
117
+ d = 1;
118
+ } else {
119
+ d = 1 - (timeMs - window.endMs) / DUCKING_RAMP_MS;
120
+ }
121
+ if (d > ducked) ducked = d;
122
+ if (ducked === 1) break;
123
+ }
124
+
125
+ const level = musicVolume + (duckingVolume - musicVolume) * ducked;
126
+
127
+ const fadeIn = Math.min(1, timeMs / MUSIC_FADE_IN_MS);
128
+ const fadeOut = Math.min(1, (totalDurationMs - timeMs) / MUSIC_FADE_OUT_MS);
129
+
130
+ return level * fadeIn * fadeOut;
131
+ }
@@ -1,11 +1,36 @@
1
- import type { NarrationManifest } from "../narration/types.js";
2
1
  /**
3
2
  * Minimal step shape for timeline computation. Accepts any
4
- * ScenarioStep<T> without caring about the data payload.
3
+ * ScenarioStep<T> without caring about the data payload. The optional
4
+ * `card` marker participates because a synthesized outro card's
5
+ * duration IS the closing dwell (see below).
5
6
  */
6
7
  interface StepTiming {
7
8
  delayMs: number;
9
+ card?: {
10
+ durationMs?: number;
11
+ };
8
12
  }
13
+ /**
14
+ * Minimal manifest shape for timeline computation: any positional
15
+ * manifest whose entries carry a duration. Accepts the narration
16
+ * manifest, and — while the player is muted with a presenter track —
17
+ * the presenter manifest, whose clip durations equal the narration's
18
+ * for the same steps (the clip is derived from that audio). Feeding
19
+ * the presenter manifest to the muted timeline keeps the progress
20
+ * bar, scrubbing, and step advancement in agreement on presenter
21
+ * steps, and converges muted timing on the export timeline there.
22
+ */
23
+ interface ManifestTiming {
24
+ readonly steps: readonly ({
25
+ readonly durationMs: number;
26
+ } | null | undefined)[];
27
+ }
28
+ /**
29
+ * Dwell time on the final step so viewers can absorb the result.
30
+ * Also the delay `applyTitleCards` gives a synthesized outro card, so
31
+ * the last authored step keeps exactly this dwell before the card.
32
+ */
33
+ export declare const FINAL_DWELL_MS = 3000;
9
34
  export interface StepTimeline {
10
35
  /** Start time of each step in milliseconds (index 0 is always 0). */
11
36
  stepStartTimesMs: number[];
@@ -22,6 +47,6 @@ export interface StepTimeline {
22
47
  * Shared between browser ScenarioPlayer (progress bar) and Remotion
23
48
  * video export (frame-based timeline).
24
49
  */
25
- export declare function computeStepTimeline(steps: readonly StepTiming[], manifest: NarrationManifest | null | undefined): StepTimeline;
50
+ export declare function computeStepTimeline(steps: readonly StepTiming[], manifest: ManifestTiming | null | undefined): StepTimeline;
26
51
  export {};
27
52
  //# sourceMappingURL=compute-step-timeline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"compute-step-timeline.d.ts","sourceRoot":"","sources":["../../src/timeline/compute-step-timeline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE/D;;;GAGG;AACH,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAKD,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,+CAA+C;IAC/C,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,UAAU,EAAE,EAC5B,QAAQ,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,GAC7C,YAAY,CAed"}
1
+ {"version":3,"file":"compute-step-timeline.d.ts","sourceRoot":"","sources":["../../src/timeline/compute-step-timeline.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC;AAED;;;;;;;;;GASG;AACH,UAAU,cAAc;IACtB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;QAAE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,CAAC;CACjF;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,OAAQ,CAAC;AAEpC,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,+CAA+C;IAC/C,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,UAAU,EAAE,EAC5B,QAAQ,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,GAC1C,YAAY,CAmBd"}
@@ -1,5 +1,9 @@
1
- /** Dwell time on the final step so viewers can absorb the result. */
2
- const FINAL_DWELL_MS = 3_000;
1
+ /**
2
+ * Dwell time on the final step so viewers can absorb the result.
3
+ * Also the delay `applyTitleCards` gives a synthesized outro card, so
4
+ * the last authored step keeps exactly this dwell before the card.
5
+ */
6
+ export const FINAL_DWELL_MS = 3_000;
3
7
  /**
4
8
  * Pre-compute step start times and total duration from step definitions
5
9
  * and an optional narration manifest.
@@ -20,7 +24,11 @@ export function computeStepTimeline(steps, manifest) {
20
24
  }
21
25
  const lastStepStart = stepStartTimesMs[stepStartTimesMs.length - 1] ?? 0;
22
26
  const lastNarrationMs = manifest?.steps[steps.length - 1]?.durationMs ?? 0;
23
- const totalDurationMs = lastStepStart + Math.max(FINAL_DWELL_MS, lastNarrationMs);
27
+ // A final card step (a synthesized outro) dwells for its configured
28
+ // duration instead of the fixed default — cards are silent, so the
29
+ // narration max is a no-op for them but kept for uniformity.
30
+ const closingDwellMs = steps[steps.length - 1]?.card?.durationMs ?? FINAL_DWELL_MS;
31
+ const totalDurationMs = lastStepStart + Math.max(closingDwellMs, lastNarrationMs);
24
32
  return { stepStartTimesMs, totalDurationMs };
25
33
  }
26
34
  //# sourceMappingURL=compute-step-timeline.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"compute-step-timeline.js","sourceRoot":"","sources":["../../src/timeline/compute-step-timeline.ts"],"names":[],"mappings":"AAUA,qEAAqE;AACrE,MAAM,cAAc,GAAG,KAAK,CAAC;AAS7B;;;;;;;;;GASG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAA4B,EAC5B,QAA8C;IAE9C,MAAM,gBAAgB,GAAa,CAAC,CAAC,CAAC,CAAC;IAEvC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,MAAM,SAAS,GAAG,gBAAgB,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC;QAC3C,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC,OAAO,CAAC;QACpC,MAAM,WAAW,GAAG,QAAQ,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,UAAU,IAAI,CAAC,CAAC;QAC5D,gBAAgB,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC,CAAC;IACtE,CAAC;IAED,MAAM,aAAa,GAAG,gBAAgB,CAAC,gBAAgB,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;IACzE,MAAM,eAAe,GAAG,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,UAAU,IAAI,CAAC,CAAC;IAC3E,MAAM,eAAe,GAAG,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,cAAc,EAAE,eAAe,CAAC,CAAC;IAElF,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;AAC/C,CAAC"}
1
+ {"version":3,"file":"compute-step-timeline.js","sourceRoot":"","sources":["../../src/timeline/compute-step-timeline.ts"],"names":[],"mappings":"AAyBA;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,CAAC;AASpC;;;;;;;;;GASG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAA4B,EAC5B,QAA2C;IAE3C,MAAM,gBAAgB,GAAa,CAAC,CAAC,CAAC,CAAC;IAEvC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,MAAM,SAAS,GAAG,gBAAgB,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC;QAC3C,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC,OAAO,CAAC;QACpC,MAAM,WAAW,GAAG,QAAQ,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,UAAU,IAAI,CAAC,CAAC;QAC5D,gBAAgB,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC,CAAC;IACtE,CAAC;IAED,MAAM,aAAa,GAAG,gBAAgB,CAAC,gBAAgB,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;IACzE,MAAM,eAAe,GAAG,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,UAAU,IAAI,CAAC,CAAC;IAC3E,oEAAoE;IACpE,mEAAmE;IACnE,6DAA6D;IAC7D,MAAM,cAAc,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,UAAU,IAAI,cAAc,CAAC;IACnF,MAAM,eAAe,GAAG,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,cAAc,EAAE,eAAe,CAAC,CAAC;IAElF,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,40 @@
1
+ import type { StepAction } from "../scenario/step-action.js";
2
+ /**
3
+ * The observable sub-events of a step interaction, in the vocabulary the
4
+ * engine's effect layer uses: a `click` action is a cursor move followed
5
+ * by a click dispatch; a `type` action is a cursor move followed by one
6
+ * keystroke per character; and so on.
7
+ */
8
+ export type ActionEventKind = "cursor-move" | "click-dispatch" | "keystroke" | "hover-enter" | "hover-leave" | "drag-press" | "drag-move" | "drag-release" | "viewport-transition" | "simple-dispatch";
9
+ /** One timed sub-event of a step interaction. */
10
+ export interface ActionEvent {
11
+ readonly kind: ActionEventKind;
12
+ /**
13
+ * When this event fires, in milliseconds relative to the step's entry,
14
+ * at playback rate 1. Consumers scale for playback rate (the browser
15
+ * scheduler divides by rate; video export always runs at rate 1).
16
+ */
17
+ readonly offsetMs: number;
18
+ /** Zero-based character index. Present only on `keystroke` events. */
19
+ readonly charIndex?: number;
20
+ }
21
+ /**
22
+ * Derive the timed sub-events of a single step interaction — the
23
+ * canonical statement of the engine's event-offset math.
24
+ *
25
+ * The offsets below are the exact times at which the interaction
26
+ * schedulers in `@scenar/react` (`useBrowserStepInteractions`,
27
+ * `useTimeSourceStepInteractions`) dispatch their effects; parity tests
28
+ * in that package pin the schedulers to this derivation. Sound-effect
29
+ * placement (`deriveSfxTimeline`) is built on it, so a sound can never
30
+ * drift from the visual it accompanies.
31
+ *
32
+ * Pure function of its arguments: same action and duration, same events,
33
+ * in both browser playback and video export.
34
+ *
35
+ * @param action - The interaction to derive events for.
36
+ * @param stepDurationMs - The step's effective duration (see
37
+ * `getStepDurationMs`); `atPercent` anchors against it.
38
+ */
39
+ export declare function deriveActionEvents(action: StepAction, stepDurationMs: number): ActionEvent[];
40
+ //# sourceMappingURL=derive-action-events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-action-events.d.ts","sourceRoot":"","sources":["../../src/timeline/derive-action-events.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAE7D;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GACvB,aAAa,GACb,gBAAgB,GAChB,WAAW,GACX,aAAa,GACb,aAAa,GACb,YAAY,GACZ,WAAW,GACX,cAAc,GACd,qBAAqB,GACrB,iBAAiB,CAAC;AAEtB,iDAAiD;AACjD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,cAAc,EAAE,MAAM,GACrB,WAAW,EAAE,CAuDf"}
@@ -0,0 +1,71 @@
1
+ import { CLICK_DELAY_MS, DRAG_SETTLE_MS, HOVER_HOLD_MS, TYPE_CHAR_DELAY_MS, } from "../timing/constants.js";
2
+ /**
3
+ * Derive the timed sub-events of a single step interaction — the
4
+ * canonical statement of the engine's event-offset math.
5
+ *
6
+ * The offsets below are the exact times at which the interaction
7
+ * schedulers in `@scenar/react` (`useBrowserStepInteractions`,
8
+ * `useTimeSourceStepInteractions`) dispatch their effects; parity tests
9
+ * in that package pin the schedulers to this derivation. Sound-effect
10
+ * placement (`deriveSfxTimeline`) is built on it, so a sound can never
11
+ * drift from the visual it accompanies.
12
+ *
13
+ * Pure function of its arguments: same action and duration, same events,
14
+ * in both browser playback and video export.
15
+ *
16
+ * @param action - The interaction to derive events for.
17
+ * @param stepDurationMs - The step's effective duration (see
18
+ * `getStepDurationMs`); `atPercent` anchors against it.
19
+ */
20
+ export function deriveActionEvents(action, stepDurationMs) {
21
+ const fireAt = action.atPercent * stepDurationMs;
22
+ switch (action.type) {
23
+ case "click":
24
+ return [
25
+ { kind: "cursor-move", offsetMs: fireAt },
26
+ { kind: "click-dispatch", offsetMs: fireAt + CLICK_DELAY_MS },
27
+ ];
28
+ case "type": {
29
+ const text = action.text ?? "";
30
+ // An empty type action is a no-op in the schedulers (not even a
31
+ // cursor move), so it derives no events.
32
+ if (text.length === 0)
33
+ return [];
34
+ const charDelay = action.typeDelay ?? TYPE_CHAR_DELAY_MS;
35
+ const typingStart = fireAt + CLICK_DELAY_MS;
36
+ const events = [{ kind: "cursor-move", offsetMs: fireAt }];
37
+ for (let i = 0; i < text.length; i++) {
38
+ events.push({
39
+ kind: "keystroke",
40
+ offsetMs: typingStart + i * charDelay,
41
+ charIndex: i,
42
+ });
43
+ }
44
+ return events;
45
+ }
46
+ case "hover": {
47
+ const holdMs = action.hoverDuration ?? HOVER_HOLD_MS;
48
+ return [
49
+ { kind: "cursor-move", offsetMs: fireAt },
50
+ { kind: "hover-enter", offsetMs: fireAt + CLICK_DELAY_MS },
51
+ { kind: "hover-leave", offsetMs: fireAt + CLICK_DELAY_MS + holdMs },
52
+ ];
53
+ }
54
+ case "drag":
55
+ return [
56
+ { kind: "cursor-move", offsetMs: fireAt },
57
+ { kind: "drag-press", offsetMs: fireAt + CLICK_DELAY_MS },
58
+ { kind: "drag-move", offsetMs: fireAt + CLICK_DELAY_MS + DRAG_SETTLE_MS },
59
+ {
60
+ kind: "drag-release",
61
+ offsetMs: fireAt + CLICK_DELAY_MS + DRAG_SETTLE_MS + CLICK_DELAY_MS,
62
+ },
63
+ ];
64
+ case "viewport_transition":
65
+ return [{ kind: "viewport-transition", offsetMs: fireAt }];
66
+ // scroll_to, set_cursor, clear_cursor: a single dispatch at fireAt.
67
+ default:
68
+ return [{ kind: "simple-dispatch", offsetMs: fireAt }];
69
+ }
70
+ }
71
+ //# sourceMappingURL=derive-action-events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-action-events.js","sourceRoot":"","sources":["../../src/timeline/derive-action-events.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,cAAc,EACd,aAAa,EACb,kBAAkB,GACnB,MAAM,wBAAwB,CAAC;AAkChC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,kBAAkB,CAChC,MAAkB,EAClB,cAAsB;IAEtB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,GAAG,cAAc,CAAC;IAEjD,QAAQ,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,KAAK,OAAO;YACV,OAAO;gBACL,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE;gBACzC,EAAE,IAAI,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,EAAE;aAC9D,CAAC;QAEJ,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;YAC/B,gEAAgE;YAChE,yCAAyC;YACzC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAO,EAAE,CAAC;YACjC,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,kBAAkB,CAAC;YACzD,MAAM,WAAW,GAAG,MAAM,GAAG,cAAc,CAAC;YAC5C,MAAM,MAAM,GAAkB,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;YAC1E,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACrC,MAAM,CAAC,IAAI,CAAC;oBACV,IAAI,EAAE,WAAW;oBACjB,QAAQ,EAAE,WAAW,GAAG,CAAC,GAAG,SAAS;oBACrC,SAAS,EAAE,CAAC;iBACb,CAAC,CAAC;YACL,CAAC;YACD,OAAO,MAAM,CAAC;QAChB,CAAC;QAED,KAAK,OAAO,CAAC,CAAC,CAAC;YACb,MAAM,MAAM,GAAG,MAAM,CAAC,aAAa,IAAI,aAAa,CAAC;YACrD,OAAO;gBACL,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE;gBACzC,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,EAAE;gBAC1D,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,MAAM,EAAE;aACpE,CAAC;QACJ,CAAC;QAED,KAAK,MAAM;YACT,OAAO;gBACL,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE;gBACzC,EAAE,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,EAAE;gBACzD,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,cAAc,EAAE;gBACzE;oBACE,IAAI,EAAE,cAAc;oBACpB,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,cAAc,GAAG,cAAc;iBACpE;aACF,CAAC;QAEJ,KAAK,qBAAqB;YACxB,OAAO,CAAC,EAAE,IAAI,EAAE,qBAAqB,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QAE7D,oEAAoE;QACpE;YACE,OAAO,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IAC3D,CAAC;AACH,CAAC"}
@@ -0,0 +1,36 @@
1
+ import type { NarrationManifest } from "../narration/types.js";
2
+ import type { ScenarioStep } from "../scenario/types.js";
3
+ /**
4
+ * The engine's built-in sound-effect vocabulary. Deliberately minimal:
5
+ * a click sound and a keystroke sound sell realism; everything else
6
+ * (hover, scroll, camera moves) stays silent.
7
+ */
8
+ export type SfxSound = "click" | "keystroke";
9
+ /** One sound effect placed on the scenario timeline. */
10
+ export interface SfxEvent {
11
+ /** The step during which this sound fires. */
12
+ readonly stepIndex: number;
13
+ /**
14
+ * When the sound fires, in milliseconds relative to the step's entry,
15
+ * at playback rate 1 — the same time base as `ActionEvent.offsetMs`,
16
+ * so the sound and the visual it accompanies share one clock.
17
+ */
18
+ readonly offsetMs: number;
19
+ readonly sound: SfxSound;
20
+ }
21
+ /**
22
+ * Derive every sound effect in the scenario from its interactions —
23
+ * placement is computed, never authored.
24
+ *
25
+ * Built on `deriveActionEvents`, so each sound fires at the exact moment
26
+ * its interaction dispatches: the click sound when the DOM click fires
27
+ * (after the cursor's travel window), one keystroke sound per typed
28
+ * character at the typing cadence, click sounds at drag press and
29
+ * release. Consumers place the events: video export converts offsets to
30
+ * absolute frames via the step timeline; browser playback schedules them
31
+ * on step entry, exactly like the interaction schedulers.
32
+ *
33
+ * Events are ordered by step, then by offset within the step.
34
+ */
35
+ export declare function deriveSfxTimeline<T>(steps: readonly ScenarioStep<T>[], manifest: NarrationManifest | undefined): SfxEvent[];
36
+ //# sourceMappingURL=derive-sfx-timeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-sfx-timeline.d.ts","sourceRoot":"","sources":["../../src/timeline/derive-sfx-timeline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAIzD;;;;GAIG;AACH,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,WAAW,CAAC;AAE7C,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACvB,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAcD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,KAAK,EAAE,SAAS,YAAY,CAAC,CAAC,CAAC,EAAE,EACjC,QAAQ,EAAE,iBAAiB,GAAG,SAAS,GACtC,QAAQ,EAAE,CAwBZ"}
@@ -0,0 +1,49 @@
1
+ import { getStepDurationMs } from "./step-duration.js";
2
+ import { deriveActionEvents } from "./derive-action-events.js";
3
+ /**
4
+ * Which action sub-events make a sound. Click dispatches and drag
5
+ * press/release share the click sound (a drag is a press and a release);
6
+ * each typed character gets a keystroke sound.
7
+ */
8
+ const SOUND_BY_EVENT_KIND = {
9
+ "click-dispatch": "click",
10
+ "drag-press": "click",
11
+ "drag-release": "click",
12
+ keystroke: "keystroke",
13
+ };
14
+ /**
15
+ * Derive every sound effect in the scenario from its interactions —
16
+ * placement is computed, never authored.
17
+ *
18
+ * Built on `deriveActionEvents`, so each sound fires at the exact moment
19
+ * its interaction dispatches: the click sound when the DOM click fires
20
+ * (after the cursor's travel window), one keystroke sound per typed
21
+ * character at the typing cadence, click sounds at drag press and
22
+ * release. Consumers place the events: video export converts offsets to
23
+ * absolute frames via the step timeline; browser playback schedules them
24
+ * on step entry, exactly like the interaction schedulers.
25
+ *
26
+ * Events are ordered by step, then by offset within the step.
27
+ */
28
+ export function deriveSfxTimeline(steps, manifest) {
29
+ const events = [];
30
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
31
+ const actions = steps[stepIndex]?.interactions;
32
+ if (!actions || actions.length === 0)
33
+ continue;
34
+ const duration = getStepDurationMs(stepIndex, manifest, steps);
35
+ const stepEvents = [];
36
+ for (const action of actions) {
37
+ for (const event of deriveActionEvents(action, duration)) {
38
+ const sound = SOUND_BY_EVENT_KIND[event.kind];
39
+ if (sound) {
40
+ stepEvents.push({ stepIndex, offsetMs: event.offsetMs, sound });
41
+ }
42
+ }
43
+ }
44
+ stepEvents.sort((a, b) => a.offsetMs - b.offsetMs);
45
+ events.push(...stepEvents);
46
+ }
47
+ return events;
48
+ }
49
+ //# sourceMappingURL=derive-sfx-timeline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-sfx-timeline.js","sourceRoot":"","sources":["../../src/timeline/derive-sfx-timeline.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,kBAAkB,EAAwB,MAAM,2BAA2B,CAAC;AAsBrF;;;;GAIG;AACH,MAAM,mBAAmB,GAA+C;IACtE,gBAAgB,EAAE,OAAO;IACzB,YAAY,EAAE,OAAO;IACrB,cAAc,EAAE,OAAO;IACvB,SAAS,EAAE,WAAW;CACvB,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAC/B,KAAiC,EACjC,QAAuC;IAEvC,MAAM,MAAM,GAAe,EAAE,CAAC;IAE9B,KAAK,IAAI,SAAS,GAAG,CAAC,EAAE,SAAS,GAAG,KAAK,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,CAAC;QAC9D,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,CAAC,EAAE,YAAY,CAAC;QAC/C,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QAE/C,MAAM,QAAQ,GAAG,iBAAiB,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;QAC/D,MAAM,UAAU,GAAe,EAAE,CAAC;QAElC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,KAAK,MAAM,KAAK,IAAI,kBAAkB,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC;gBACzD,MAAM,KAAK,GAAG,mBAAmB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;gBAC9C,IAAI,KAAK,EAAE,CAAC;oBACV,UAAU,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;gBAClE,CAAC;YACH,CAAC;QACH,CAAC;QAED,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QACnD,MAAM,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,CAAC;IAC7B,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,69 @@
1
+ import type { NarrationManifest } from "../narration/types.js";
2
+ import { type Soundtrack } from "../scenario/soundtrack.js";
3
+ /**
4
+ * Milliseconds over which the music fades in from silence at scenario
5
+ * start. Long enough to feel intentional, short enough that the opening
6
+ * step is never dry.
7
+ */
8
+ export declare const MUSIC_FADE_IN_MS = 1000;
9
+ /**
10
+ * Milliseconds over which the music fades out at the end of the
11
+ * scenario. Matches the final-dwell window the timeline already reserves
12
+ * after the last step, so the video never ends on a hard musical cut.
13
+ */
14
+ export declare const MUSIC_FADE_OUT_MS = 3000;
15
+ /**
16
+ * Milliseconds the music takes to ramp between its base level and its
17
+ * ducked level at each narration boundary. The ramp completes as the
18
+ * voice starts (pre-duck) so narration onset is never fighting the ramp;
19
+ * felt, not heard as a cut.
20
+ */
21
+ export declare const DUCKING_RAMP_MS = 300;
22
+ /** A span of the scenario timeline during which a narration clip plays. */
23
+ export interface DuckingWindow {
24
+ readonly startMs: number;
25
+ readonly endMs: number;
26
+ }
27
+ /**
28
+ * Everything needed to compute the music level at any point on the
29
+ * scenario timeline. Precomputed once per scenario; `musicGainAt` reads
30
+ * it per sample/frame without allocating.
31
+ */
32
+ export interface MusicEnvelope {
33
+ /** Resolved base music level (0–1). */
34
+ readonly musicVolume: number;
35
+ /** Resolved ducked music level while narration plays (0–1). */
36
+ readonly duckingVolume: number;
37
+ /** Total scenario duration, from `computeStepTimeline`. */
38
+ readonly totalDurationMs: number;
39
+ /** Narration clip spans on the timeline, in step order. */
40
+ readonly duckingWindows: readonly DuckingWindow[];
41
+ }
42
+ /**
43
+ * Precompute the music envelope for a scenario: resolved volume levels
44
+ * plus the narration windows the music ducks under.
45
+ *
46
+ * The windows derive from the same `computeStepTimeline` both output
47
+ * paths already share — a clip for step N spans from that step's start
48
+ * for the clip's duration — so ducking agrees with narration placement
49
+ * by construction, in browser playback and video export alike.
50
+ */
51
+ export declare function computeMusicEnvelope(steps: readonly {
52
+ delayMs: number;
53
+ }[], manifest: NarrationManifest | null | undefined, soundtrack: Soundtrack): MusicEnvelope;
54
+ /**
55
+ * The music level at a point on the scenario timeline (0–1).
56
+ *
57
+ * Composed of three factors:
58
+ * - a fade-in from silence over {@link MUSIC_FADE_IN_MS} at the start,
59
+ * - a fade-out to silence over {@link MUSIC_FADE_OUT_MS} at the end,
60
+ * - narration ducking: at the base level away from narration, at the
61
+ * ducked level while a clip plays, ramping over {@link DUCKING_RAMP_MS}
62
+ * into each window (completing at voice onset) and out after it ends.
63
+ *
64
+ * Pure and allocation-free: the browser applies it through Web Audio
65
+ * gain automation, video export as a per-frame volume function. Outside
66
+ * the scenario ([0, totalDurationMs]) the level is 0.
67
+ */
68
+ export declare function musicGainAt(envelope: MusicEnvelope, timeMs: number): number;
69
+ //# sourceMappingURL=music-envelope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"music-envelope.d.ts","sourceRoot":"","sources":["../../src/timeline/music-envelope.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,2BAA2B,CAAC;AAGnC;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,OAAQ,CAAC;AAEvC;;;;;GAKG;AACH,eAAO,MAAM,eAAe,MAAM,CAAC;AAEnC,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,uCAAuC;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,+DAA+D;IAC/D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,2DAA2D;IAC3D,QAAQ,CAAC,cAAc,EAAE,SAAS,aAAa,EAAE,CAAC;CACnD;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,SAAS;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,EACrC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EAC9C,UAAU,EAAE,UAAU,GACrB,aAAa,CAoBf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CA6B3E"}
@@ -0,0 +1,95 @@
1
+ import { DUCKING_VOLUME_DEFAULT, MUSIC_VOLUME_DEFAULT, } from "../scenario/soundtrack.js";
2
+ import { computeStepTimeline } from "./compute-step-timeline.js";
3
+ /**
4
+ * Milliseconds over which the music fades in from silence at scenario
5
+ * start. Long enough to feel intentional, short enough that the opening
6
+ * step is never dry.
7
+ */
8
+ export const MUSIC_FADE_IN_MS = 1_000;
9
+ /**
10
+ * Milliseconds over which the music fades out at the end of the
11
+ * scenario. Matches the final-dwell window the timeline already reserves
12
+ * after the last step, so the video never ends on a hard musical cut.
13
+ */
14
+ export const MUSIC_FADE_OUT_MS = 3_000;
15
+ /**
16
+ * Milliseconds the music takes to ramp between its base level and its
17
+ * ducked level at each narration boundary. The ramp completes as the
18
+ * voice starts (pre-duck) so narration onset is never fighting the ramp;
19
+ * felt, not heard as a cut.
20
+ */
21
+ export const DUCKING_RAMP_MS = 300;
22
+ /**
23
+ * Precompute the music envelope for a scenario: resolved volume levels
24
+ * plus the narration windows the music ducks under.
25
+ *
26
+ * The windows derive from the same `computeStepTimeline` both output
27
+ * paths already share — a clip for step N spans from that step's start
28
+ * for the clip's duration — so ducking agrees with narration placement
29
+ * by construction, in browser playback and video export alike.
30
+ */
31
+ export function computeMusicEnvelope(steps, manifest, soundtrack) {
32
+ const { stepStartTimesMs, totalDurationMs } = computeStepTimeline(steps, manifest);
33
+ const duckingWindows = [];
34
+ if (manifest) {
35
+ for (let i = 0; i < steps.length; i++) {
36
+ const durationMs = manifest.steps[i]?.durationMs ?? 0;
37
+ if (durationMs > 0) {
38
+ const startMs = stepStartTimesMs[i] ?? 0;
39
+ duckingWindows.push({ startMs, endMs: startMs + durationMs });
40
+ }
41
+ }
42
+ }
43
+ return {
44
+ musicVolume: soundtrack.musicVolume ?? MUSIC_VOLUME_DEFAULT,
45
+ duckingVolume: soundtrack.duckingVolume ?? DUCKING_VOLUME_DEFAULT,
46
+ totalDurationMs,
47
+ duckingWindows,
48
+ };
49
+ }
50
+ /**
51
+ * The music level at a point on the scenario timeline (0–1).
52
+ *
53
+ * Composed of three factors:
54
+ * - a fade-in from silence over {@link MUSIC_FADE_IN_MS} at the start,
55
+ * - a fade-out to silence over {@link MUSIC_FADE_OUT_MS} at the end,
56
+ * - narration ducking: at the base level away from narration, at the
57
+ * ducked level while a clip plays, ramping over {@link DUCKING_RAMP_MS}
58
+ * into each window (completing at voice onset) and out after it ends.
59
+ *
60
+ * Pure and allocation-free: the browser applies it through Web Audio
61
+ * gain automation, video export as a per-frame volume function. Outside
62
+ * the scenario ([0, totalDurationMs]) the level is 0.
63
+ */
64
+ export function musicGainAt(envelope, timeMs) {
65
+ const { musicVolume, duckingVolume, totalDurationMs, duckingWindows } = envelope;
66
+ if (timeMs < 0 || timeMs > totalDurationMs)
67
+ return 0;
68
+ // Ducked-ness in [0, 1]: 0 at the base level, 1 fully ducked. Overlapping
69
+ // windows take the deepest value.
70
+ let ducked = 0;
71
+ for (const window of duckingWindows) {
72
+ const rampInStart = window.startMs - DUCKING_RAMP_MS;
73
+ if (timeMs < rampInStart || timeMs > window.endMs + DUCKING_RAMP_MS)
74
+ continue;
75
+ let d;
76
+ if (timeMs < window.startMs) {
77
+ d = (timeMs - rampInStart) / DUCKING_RAMP_MS;
78
+ }
79
+ else if (timeMs <= window.endMs) {
80
+ d = 1;
81
+ }
82
+ else {
83
+ d = 1 - (timeMs - window.endMs) / DUCKING_RAMP_MS;
84
+ }
85
+ if (d > ducked)
86
+ ducked = d;
87
+ if (ducked === 1)
88
+ break;
89
+ }
90
+ const level = musicVolume + (duckingVolume - musicVolume) * ducked;
91
+ const fadeIn = Math.min(1, timeMs / MUSIC_FADE_IN_MS);
92
+ const fadeOut = Math.min(1, (totalDurationMs - timeMs) / MUSIC_FADE_OUT_MS);
93
+ return level * fadeIn * fadeOut;
94
+ }
95
+ //# sourceMappingURL=music-envelope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"music-envelope.js","sourceRoot":"","sources":["../../src/timeline/music-envelope.ts"],"names":[],"mappings":"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"}