@scenar/core 0.9.1 → 0.11.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.
Files changed (81) hide show
  1. package/embed/host-controller.d.ts +7 -0
  2. package/embed/host-controller.d.ts.map +1 -1
  3. package/embed/host-controller.js +1 -0
  4. package/embed/host-controller.js.map +1 -1
  5. package/embed/protocol.d.ts +35 -3
  6. package/embed/protocol.d.ts.map +1 -1
  7. package/embed/protocol.js +37 -4
  8. package/embed/protocol.js.map +1 -1
  9. package/index.d.ts +18 -2
  10. package/index.d.ts.map +1 -1
  11. package/index.js +9 -1
  12. package/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/presenter/derive-presenter-timeline.d.ts +36 -0
  15. package/presenter/derive-presenter-timeline.d.ts.map +1 -0
  16. package/presenter/derive-presenter-timeline.js +31 -0
  17. package/presenter/derive-presenter-timeline.js.map +1 -0
  18. package/presenter/presenter-opacity.d.ts +32 -0
  19. package/presenter/presenter-opacity.d.ts.map +1 -0
  20. package/presenter/presenter-opacity.js +40 -0
  21. package/presenter/presenter-opacity.js.map +1 -0
  22. package/presenter/types.d.ts +45 -0
  23. package/presenter/types.d.ts.map +1 -0
  24. package/presenter/types.js +16 -0
  25. package/presenter/types.js.map +1 -0
  26. package/scenario/apply-title-cards.d.ts +53 -0
  27. package/scenario/apply-title-cards.d.ts.map +1 -0
  28. package/scenario/apply-title-cards.js +113 -0
  29. package/scenario/apply-title-cards.js.map +1 -0
  30. package/scenario/bundle.d.ts +15 -0
  31. package/scenario/bundle.d.ts.map +1 -1
  32. package/scenario/soundtrack.d.ts +46 -0
  33. package/scenario/soundtrack.d.ts.map +1 -0
  34. package/scenario/soundtrack.js +11 -0
  35. package/scenario/soundtrack.js.map +1 -0
  36. package/scenario/title-cards.d.ts +64 -0
  37. package/scenario/title-cards.d.ts.map +1 -0
  38. package/scenario/title-cards.js +20 -0
  39. package/scenario/title-cards.js.map +1 -0
  40. package/scenario/types.d.ts +36 -2
  41. package/scenario/types.d.ts.map +1 -1
  42. package/src/embed/host-controller.test.ts +6 -0
  43. package/src/embed/host-controller.ts +8 -0
  44. package/src/embed/protocol.test.ts +53 -0
  45. package/src/embed/protocol.ts +76 -8
  46. package/src/index.ts +33 -1
  47. package/src/presenter/derive-presenter-timeline.test.ts +138 -0
  48. package/src/presenter/derive-presenter-timeline.ts +54 -0
  49. package/src/presenter/presenter-opacity.test.ts +47 -0
  50. package/src/presenter/presenter-opacity.ts +43 -0
  51. package/src/presenter/types.ts +46 -0
  52. package/src/scenario/apply-title-cards.test.ts +268 -0
  53. package/src/scenario/apply-title-cards.ts +141 -0
  54. package/src/scenario/bundle.ts +15 -0
  55. package/src/scenario/soundtrack.ts +47 -0
  56. package/src/scenario/title-cards.ts +67 -0
  57. package/src/scenario/types.ts +36 -2
  58. package/src/timeline/compute-step-timeline.test.ts +21 -0
  59. package/src/timeline/compute-step-timeline.ts +30 -7
  60. package/src/timeline/derive-action-events.test.ts +144 -0
  61. package/src/timeline/derive-action-events.ts +116 -0
  62. package/src/timeline/derive-sfx-timeline.test.ts +114 -0
  63. package/src/timeline/derive-sfx-timeline.ts +79 -0
  64. package/src/timeline/music-envelope.test.ts +141 -0
  65. package/src/timeline/music-envelope.ts +131 -0
  66. package/timeline/compute-step-timeline.d.ts +28 -3
  67. package/timeline/compute-step-timeline.d.ts.map +1 -1
  68. package/timeline/compute-step-timeline.js +11 -3
  69. package/timeline/compute-step-timeline.js.map +1 -1
  70. package/timeline/derive-action-events.d.ts +40 -0
  71. package/timeline/derive-action-events.d.ts.map +1 -0
  72. package/timeline/derive-action-events.js +71 -0
  73. package/timeline/derive-action-events.js.map +1 -0
  74. package/timeline/derive-sfx-timeline.d.ts +36 -0
  75. package/timeline/derive-sfx-timeline.d.ts.map +1 -0
  76. package/timeline/derive-sfx-timeline.js +49 -0
  77. package/timeline/derive-sfx-timeline.js.map +1 -0
  78. package/timeline/music-envelope.d.ts +69 -0
  79. package/timeline/music-envelope.d.ts.map +1 -0
  80. package/timeline/music-envelope.js +95 -0
  81. package/timeline/music-envelope.js.map +1 -0
@@ -0,0 +1,116 @@
1
+ import {
2
+ CLICK_DELAY_MS,
3
+ DRAG_SETTLE_MS,
4
+ HOVER_HOLD_MS,
5
+ TYPE_CHAR_DELAY_MS,
6
+ } from "../timing/constants.js";
7
+ import type { StepAction } from "../scenario/step-action.js";
8
+
9
+ /**
10
+ * The observable sub-events of a step interaction, in the vocabulary the
11
+ * engine's effect layer uses: a `click` action is a cursor move followed
12
+ * by a click dispatch; a `type` action is a cursor move followed by one
13
+ * keystroke per character; and so on.
14
+ */
15
+ export type ActionEventKind =
16
+ | "cursor-move"
17
+ | "click-dispatch"
18
+ | "keystroke"
19
+ | "hover-enter"
20
+ | "hover-leave"
21
+ | "drag-press"
22
+ | "drag-move"
23
+ | "drag-release"
24
+ | "viewport-transition"
25
+ | "simple-dispatch";
26
+
27
+ /** One timed sub-event of a step interaction. */
28
+ export interface ActionEvent {
29
+ readonly kind: ActionEventKind;
30
+ /**
31
+ * When this event fires, in milliseconds relative to the step's entry,
32
+ * at playback rate 1. Consumers scale for playback rate (the browser
33
+ * scheduler divides by rate; video export always runs at rate 1).
34
+ */
35
+ readonly offsetMs: number;
36
+ /** Zero-based character index. Present only on `keystroke` events. */
37
+ readonly charIndex?: number;
38
+ }
39
+
40
+ /**
41
+ * Derive the timed sub-events of a single step interaction — the
42
+ * canonical statement of the engine's event-offset math.
43
+ *
44
+ * The offsets below are the exact times at which the interaction
45
+ * schedulers in `@scenar/react` (`useBrowserStepInteractions`,
46
+ * `useTimeSourceStepInteractions`) dispatch their effects; parity tests
47
+ * in that package pin the schedulers to this derivation. Sound-effect
48
+ * placement (`deriveSfxTimeline`) is built on it, so a sound can never
49
+ * drift from the visual it accompanies.
50
+ *
51
+ * Pure function of its arguments: same action and duration, same events,
52
+ * in both browser playback and video export.
53
+ *
54
+ * @param action - The interaction to derive events for.
55
+ * @param stepDurationMs - The step's effective duration (see
56
+ * `getStepDurationMs`); `atPercent` anchors against it.
57
+ */
58
+ export function deriveActionEvents(
59
+ action: StepAction,
60
+ stepDurationMs: number,
61
+ ): ActionEvent[] {
62
+ const fireAt = action.atPercent * stepDurationMs;
63
+
64
+ switch (action.type) {
65
+ case "click":
66
+ return [
67
+ { kind: "cursor-move", offsetMs: fireAt },
68
+ { kind: "click-dispatch", offsetMs: fireAt + CLICK_DELAY_MS },
69
+ ];
70
+
71
+ case "type": {
72
+ const text = action.text ?? "";
73
+ // An empty type action is a no-op in the schedulers (not even a
74
+ // cursor move), so it derives no events.
75
+ if (text.length === 0) return [];
76
+ const charDelay = action.typeDelay ?? TYPE_CHAR_DELAY_MS;
77
+ const typingStart = fireAt + CLICK_DELAY_MS;
78
+ const events: ActionEvent[] = [{ kind: "cursor-move", offsetMs: fireAt }];
79
+ for (let i = 0; i < text.length; i++) {
80
+ events.push({
81
+ kind: "keystroke",
82
+ offsetMs: typingStart + i * charDelay,
83
+ charIndex: i,
84
+ });
85
+ }
86
+ return events;
87
+ }
88
+
89
+ case "hover": {
90
+ const holdMs = action.hoverDuration ?? HOVER_HOLD_MS;
91
+ return [
92
+ { kind: "cursor-move", offsetMs: fireAt },
93
+ { kind: "hover-enter", offsetMs: fireAt + CLICK_DELAY_MS },
94
+ { kind: "hover-leave", offsetMs: fireAt + CLICK_DELAY_MS + holdMs },
95
+ ];
96
+ }
97
+
98
+ case "drag":
99
+ return [
100
+ { kind: "cursor-move", offsetMs: fireAt },
101
+ { kind: "drag-press", offsetMs: fireAt + CLICK_DELAY_MS },
102
+ { kind: "drag-move", offsetMs: fireAt + CLICK_DELAY_MS + DRAG_SETTLE_MS },
103
+ {
104
+ kind: "drag-release",
105
+ offsetMs: fireAt + CLICK_DELAY_MS + DRAG_SETTLE_MS + CLICK_DELAY_MS,
106
+ },
107
+ ];
108
+
109
+ case "viewport_transition":
110
+ return [{ kind: "viewport-transition", offsetMs: fireAt }];
111
+
112
+ // scroll_to, set_cursor, clear_cursor: a single dispatch at fireAt.
113
+ default:
114
+ return [{ kind: "simple-dispatch", offsetMs: fireAt }];
115
+ }
116
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { NarrationManifest } from "../narration/types.js";
3
+ import type { ScenarioStep } from "../scenario/types.js";
4
+ import type { StepAction } from "../scenario/step-action.js";
5
+ import { deriveSfxTimeline } from "./derive-sfx-timeline.js";
6
+ import {
7
+ CLICK_DELAY_MS,
8
+ DRAG_SETTLE_MS,
9
+ TYPE_CHAR_DELAY_MS,
10
+ } from "../timing/constants.js";
11
+
12
+ function step(
13
+ delayMs: number,
14
+ interactions?: StepAction[],
15
+ ): ScenarioStep<Record<string, never>> {
16
+ return { delayMs, data: {}, interactions };
17
+ }
18
+
19
+ describe("deriveSfxTimeline", () => {
20
+ it("returns no events for a scenario without interactions", () => {
21
+ expect(deriveSfxTimeline([step(0), step(1000)], undefined)).toEqual([]);
22
+ });
23
+
24
+ it("click yields one click sound at the dispatch moment", () => {
25
+ const steps = [
26
+ step(0, [{ atPercent: 0.5, type: "click", target: "btn" }]),
27
+ step(2000),
28
+ ];
29
+ // Step 0's duration is the next step's delayMs (2000) — no narration.
30
+ expect(deriveSfxTimeline(steps, undefined)).toEqual([
31
+ { stepIndex: 0, offsetMs: 1000 + CLICK_DELAY_MS, sound: "click" },
32
+ ]);
33
+ });
34
+
35
+ it("typing yields one keystroke sound per character", () => {
36
+ const steps = [
37
+ step(0, [{ atPercent: 0, type: "type", target: "input", text: "hi" }]),
38
+ step(1000),
39
+ ];
40
+ expect(deriveSfxTimeline(steps, undefined)).toEqual([
41
+ { stepIndex: 0, offsetMs: CLICK_DELAY_MS, sound: "keystroke" },
42
+ { stepIndex: 0, offsetMs: CLICK_DELAY_MS + TYPE_CHAR_DELAY_MS, sound: "keystroke" },
43
+ ]);
44
+ });
45
+
46
+ it("drag yields click sounds at press and release, nothing between", () => {
47
+ const steps = [
48
+ step(0, [{ atPercent: 0, type: "drag", target: "card", dragTarget: "col" }]),
49
+ step(1000),
50
+ ];
51
+ expect(deriveSfxTimeline(steps, undefined)).toEqual([
52
+ { stepIndex: 0, offsetMs: CLICK_DELAY_MS, sound: "click" },
53
+ {
54
+ stepIndex: 0,
55
+ offsetMs: CLICK_DELAY_MS + DRAG_SETTLE_MS + CLICK_DELAY_MS,
56
+ sound: "click",
57
+ },
58
+ ]);
59
+ });
60
+
61
+ it.each([
62
+ ["hover", { atPercent: 0.5, type: "hover", target: "tip" } as StepAction],
63
+ ["scroll_to", { atPercent: 0.5, type: "scroll_to", target: "list" } as StepAction],
64
+ ["set_cursor", { atPercent: 0, type: "set_cursor", target: "btn" } as StepAction],
65
+ ["clear_cursor", { atPercent: 1, type: "clear_cursor" } as StepAction],
66
+ [
67
+ "viewport_transition",
68
+ { atPercent: 0.5, type: "viewport_transition", target: "hero" } as StepAction,
69
+ ],
70
+ ])("%s stays silent", (_name, action) => {
71
+ const steps = [step(0, [action]), step(1000)];
72
+ expect(deriveSfxTimeline(steps, undefined)).toEqual([]);
73
+ });
74
+
75
+ it("narration duration anchors offsets when a clip exists", () => {
76
+ const steps = [
77
+ step(0, [{ atPercent: 0.5, type: "click", target: "btn" }]),
78
+ step(500),
79
+ ];
80
+ const manifest: NarrationManifest = {
81
+ steps: [{ src: "./step-0.mp3", durationMs: 4000 }, null],
82
+ };
83
+ // Duration comes from the clip (4000), not the next delay (500).
84
+ expect(deriveSfxTimeline(steps, manifest)).toEqual([
85
+ { stepIndex: 0, offsetMs: 2000 + CLICK_DELAY_MS, sound: "click" },
86
+ ]);
87
+ });
88
+
89
+ it("orders events by step, then by offset within the step", () => {
90
+ const steps = [
91
+ step(0, [
92
+ // Authored out of time order within the step.
93
+ { atPercent: 0.8, type: "click", target: "b" },
94
+ { atPercent: 0.1, type: "click", target: "a" },
95
+ ]),
96
+ step(1000, [{ atPercent: 0, type: "click", target: "c" }]),
97
+ step(1000),
98
+ ];
99
+ const events = deriveSfxTimeline(steps, undefined);
100
+ expect(events.map((e) => [e.stepIndex, e.offsetMs])).toEqual([
101
+ [0, 100 + CLICK_DELAY_MS],
102
+ [0, 800 + CLICK_DELAY_MS],
103
+ [1, CLICK_DELAY_MS],
104
+ ]);
105
+ });
106
+
107
+ it("last step uses the engine's fallback duration", () => {
108
+ const steps = [step(0, [{ atPercent: 1, type: "click", target: "btn" }])];
109
+ // Single-step scenario: getStepDurationMs falls back to 3000.
110
+ expect(deriveSfxTimeline(steps, undefined)).toEqual([
111
+ { stepIndex: 0, offsetMs: 3000 + CLICK_DELAY_MS, sound: "click" },
112
+ ]);
113
+ });
114
+ });
@@ -0,0 +1,79 @@
1
+ import type { NarrationManifest } from "../narration/types.js";
2
+ import type { ScenarioStep } from "../scenario/types.js";
3
+ import { getStepDurationMs } from "./step-duration.js";
4
+ import { deriveActionEvents, type ActionEventKind } from "./derive-action-events.js";
5
+
6
+ /**
7
+ * The engine's built-in sound-effect vocabulary. Deliberately minimal:
8
+ * a click sound and a keystroke sound sell realism; everything else
9
+ * (hover, scroll, camera moves) stays silent.
10
+ */
11
+ export type SfxSound = "click" | "keystroke";
12
+
13
+ /** One sound effect placed on the scenario timeline. */
14
+ export interface SfxEvent {
15
+ /** The step during which this sound fires. */
16
+ readonly stepIndex: number;
17
+ /**
18
+ * When the sound fires, in milliseconds relative to the step's entry,
19
+ * at playback rate 1 — the same time base as `ActionEvent.offsetMs`,
20
+ * so the sound and the visual it accompanies share one clock.
21
+ */
22
+ readonly offsetMs: number;
23
+ readonly sound: SfxSound;
24
+ }
25
+
26
+ /**
27
+ * Which action sub-events make a sound. Click dispatches and drag
28
+ * press/release share the click sound (a drag is a press and a release);
29
+ * each typed character gets a keystroke sound.
30
+ */
31
+ const SOUND_BY_EVENT_KIND: Partial<Record<ActionEventKind, SfxSound>> = {
32
+ "click-dispatch": "click",
33
+ "drag-press": "click",
34
+ "drag-release": "click",
35
+ keystroke: "keystroke",
36
+ };
37
+
38
+ /**
39
+ * Derive every sound effect in the scenario from its interactions —
40
+ * placement is computed, never authored.
41
+ *
42
+ * Built on `deriveActionEvents`, so each sound fires at the exact moment
43
+ * its interaction dispatches: the click sound when the DOM click fires
44
+ * (after the cursor's travel window), one keystroke sound per typed
45
+ * character at the typing cadence, click sounds at drag press and
46
+ * release. Consumers place the events: video export converts offsets to
47
+ * absolute frames via the step timeline; browser playback schedules them
48
+ * on step entry, exactly like the interaction schedulers.
49
+ *
50
+ * Events are ordered by step, then by offset within the step.
51
+ */
52
+ export function deriveSfxTimeline<T>(
53
+ steps: readonly ScenarioStep<T>[],
54
+ manifest: NarrationManifest | undefined,
55
+ ): SfxEvent[] {
56
+ const events: SfxEvent[] = [];
57
+
58
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
59
+ const actions = steps[stepIndex]?.interactions;
60
+ if (!actions || actions.length === 0) continue;
61
+
62
+ const duration = getStepDurationMs(stepIndex, manifest, steps);
63
+ const stepEvents: SfxEvent[] = [];
64
+
65
+ for (const action of actions) {
66
+ for (const event of deriveActionEvents(action, duration)) {
67
+ const sound = SOUND_BY_EVENT_KIND[event.kind];
68
+ if (sound) {
69
+ stepEvents.push({ stepIndex, offsetMs: event.offsetMs, sound });
70
+ }
71
+ }
72
+ }
73
+
74
+ stepEvents.sort((a, b) => a.offsetMs - b.offsetMs);
75
+ events.push(...stepEvents);
76
+ }
77
+
78
+ return events;
79
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { NarrationManifest } from "../narration/types.js";
3
+ import { MUSIC_VOLUME_DEFAULT, DUCKING_VOLUME_DEFAULT } from "../scenario/soundtrack.js";
4
+ import {
5
+ DUCKING_RAMP_MS,
6
+ MUSIC_FADE_IN_MS,
7
+ MUSIC_FADE_OUT_MS,
8
+ computeMusicEnvelope,
9
+ musicGainAt,
10
+ } from "./music-envelope.js";
11
+
12
+ /**
13
+ * Two steps: step 0 narrated for 5 s, step 1 silent. Timeline:
14
+ * step 0 at 0, step 1 at 5000 (narration outlasts its 1000 delay),
15
+ * total 5000 + FINAL_DWELL(3000) = 8000. Ducking window [0, 5000].
16
+ */
17
+ const STEPS = [{ delayMs: 0 }, { delayMs: 1000 }];
18
+ const MANIFEST: NarrationManifest = {
19
+ steps: [{ src: "./step-0.mp3", durationMs: 5000 }, null],
20
+ };
21
+
22
+ describe("computeMusicEnvelope", () => {
23
+ it("derives one ducking window per narrated step from the shared timeline", () => {
24
+ const envelope = computeMusicEnvelope(STEPS, MANIFEST, {});
25
+ expect(envelope.duckingWindows).toEqual([{ startMs: 0, endMs: 5000 }]);
26
+ expect(envelope.totalDurationMs).toBe(8000);
27
+ });
28
+
29
+ it("resolves unset volumes to the engine defaults", () => {
30
+ const envelope = computeMusicEnvelope(STEPS, MANIFEST, {});
31
+ expect(envelope.musicVolume).toBe(MUSIC_VOLUME_DEFAULT);
32
+ expect(envelope.duckingVolume).toBe(DUCKING_VOLUME_DEFAULT);
33
+ });
34
+
35
+ it("honors explicit volumes, including 0.0", () => {
36
+ const envelope = computeMusicEnvelope(STEPS, MANIFEST, {
37
+ musicVolume: 0.6,
38
+ duckingVolume: 0,
39
+ });
40
+ expect(envelope.musicVolume).toBe(0.6);
41
+ expect(envelope.duckingVolume).toBe(0);
42
+ });
43
+
44
+ it("no narration manifest means no ducking windows", () => {
45
+ const envelope = computeMusicEnvelope(STEPS, undefined, {});
46
+ expect(envelope.duckingWindows).toEqual([]);
47
+ });
48
+
49
+ it("skips zero-duration narration entries", () => {
50
+ const manifest: NarrationManifest = {
51
+ steps: [{ src: "./step-0.mp3", durationMs: 0 }, null],
52
+ };
53
+ expect(computeMusicEnvelope(STEPS, manifest, {}).duckingWindows).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe("musicGainAt", () => {
58
+ // A silent-scenario envelope isolates fades from ducking:
59
+ // no manifest, total 1000(delay) + 3000(dwell) = 4000.
60
+ const noDuckEnvelope = computeMusicEnvelope(STEPS, undefined, { musicVolume: 0.5 });
61
+
62
+ it("is 0 outside the scenario", () => {
63
+ expect(musicGainAt(noDuckEnvelope, -1)).toBe(0);
64
+ expect(musicGainAt(noDuckEnvelope, noDuckEnvelope.totalDurationMs + 1)).toBe(0);
65
+ });
66
+
67
+ it("fades in from silence to the base level", () => {
68
+ expect(musicGainAt(noDuckEnvelope, 0)).toBe(0);
69
+ expect(musicGainAt(noDuckEnvelope, MUSIC_FADE_IN_MS / 2)).toBeCloseTo(0.25, 10);
70
+ expect(musicGainAt(noDuckEnvelope, MUSIC_FADE_IN_MS)).toBeCloseTo(0.5, 10);
71
+ });
72
+
73
+ it("fades out to silence over the closing window", () => {
74
+ const total = noDuckEnvelope.totalDurationMs;
75
+ expect(musicGainAt(noDuckEnvelope, total - MUSIC_FADE_OUT_MS / 2)).toBeCloseTo(0.25, 10);
76
+ expect(musicGainAt(noDuckEnvelope, total)).toBe(0);
77
+ });
78
+
79
+ it("holds the base level on the plateau between fades", () => {
80
+ expect(musicGainAt(noDuckEnvelope, 1000)).toBeCloseTo(0.5, 10);
81
+ });
82
+
83
+ // Ducking assertions use a window clear of both fades: step 1 narrated
84
+ // 2000 → window [4000, 6000]; the trailing 8000-delay step pushes the
85
+ // total to 15000, so the fade-out ([12000, 15000]) can't overlap it.
86
+ const duckSteps = [{ delayMs: 0 }, { delayMs: 4000 }, { delayMs: 8000 }];
87
+ const duckManifest: NarrationManifest = {
88
+ steps: [null, { src: "./step-1.mp3", durationMs: 2000 }, null],
89
+ };
90
+ const duckEnvelope = computeMusicEnvelope(duckSteps, duckManifest, {
91
+ musicVolume: 0.5,
92
+ duckingVolume: 0.1,
93
+ });
94
+
95
+ it("is fully ducked while the narration clip plays", () => {
96
+ expect(musicGainAt(duckEnvelope, 4000)).toBeCloseTo(0.1, 10);
97
+ expect(musicGainAt(duckEnvelope, 5000)).toBeCloseTo(0.1, 10);
98
+ expect(musicGainAt(duckEnvelope, 6000)).toBeCloseTo(0.1, 10);
99
+ });
100
+
101
+ it("completes the down-ramp at voice onset (pre-duck)", () => {
102
+ const rampStart = 4000 - DUCKING_RAMP_MS;
103
+ expect(musicGainAt(duckEnvelope, rampStart)).toBeCloseTo(0.5, 10);
104
+ expect(musicGainAt(duckEnvelope, rampStart + DUCKING_RAMP_MS / 2)).toBeCloseTo(0.3, 10);
105
+ });
106
+
107
+ it("ramps back to the base level after the clip ends", () => {
108
+ expect(musicGainAt(duckEnvelope, 6000 + DUCKING_RAMP_MS / 2)).toBeCloseTo(0.3, 10);
109
+ expect(musicGainAt(duckEnvelope, 6000 + DUCKING_RAMP_MS)).toBeCloseTo(0.5, 10);
110
+ });
111
+
112
+ it("overlapping windows take the deepest duck", () => {
113
+ // Two narrated steps whose windows adjoin: the boundary sample sits in
114
+ // window 0's up-ramp AND window 1's plateau — the plateau (deeper) wins.
115
+ const steps = [{ delayMs: 0 }, { delayMs: 0 }, { delayMs: 4000 }];
116
+ const manifest: NarrationManifest = {
117
+ steps: [
118
+ { src: "./step-0.mp3", durationMs: 3000 },
119
+ { src: "./step-1.mp3", durationMs: 3000 },
120
+ null,
121
+ ],
122
+ };
123
+ const envelope = computeMusicEnvelope(steps, manifest, {
124
+ musicVolume: 0.5,
125
+ duckingVolume: 0.1,
126
+ });
127
+ // Windows: [0, 3000] and [3000, 6000] — inside window 0's up-ramp zone.
128
+ expect(musicGainAt(envelope, 3000 + DUCKING_RAMP_MS / 2)).toBeCloseTo(0.1, 10);
129
+ });
130
+
131
+ it("a ducking window during the fade-in multiplies both factors", () => {
132
+ // duckEnvelope's fade-in covers [0, 1000]; window starts at 4000 — use
133
+ // an envelope whose narration starts at 0 instead.
134
+ const envelope = computeMusicEnvelope(STEPS, MANIFEST, {
135
+ musicVolume: 0.5,
136
+ duckingVolume: 0.1,
137
+ });
138
+ // At 500ms: ducked level 0.1 × fade-in factor 0.5.
139
+ expect(musicGainAt(envelope, MUSIC_FADE_IN_MS / 2)).toBeCloseTo(0.05, 10);
140
+ });
141
+ });
@@ -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"}