@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.
- package/index.d.ts +17 -1
- package/index.d.ts.map +1 -1
- package/index.js +9 -1
- package/index.js.map +1 -1
- package/package.json +1 -1
- package/presenter/derive-presenter-timeline.d.ts +36 -0
- package/presenter/derive-presenter-timeline.d.ts.map +1 -0
- package/presenter/derive-presenter-timeline.js +31 -0
- package/presenter/derive-presenter-timeline.js.map +1 -0
- package/presenter/presenter-opacity.d.ts +32 -0
- package/presenter/presenter-opacity.d.ts.map +1 -0
- package/presenter/presenter-opacity.js +40 -0
- package/presenter/presenter-opacity.js.map +1 -0
- package/presenter/types.d.ts +45 -0
- package/presenter/types.d.ts.map +1 -0
- package/presenter/types.js +16 -0
- package/presenter/types.js.map +1 -0
- package/scenario/apply-title-cards.d.ts +53 -0
- package/scenario/apply-title-cards.d.ts.map +1 -0
- package/scenario/apply-title-cards.js +113 -0
- package/scenario/apply-title-cards.js.map +1 -0
- package/scenario/bundle.d.ts +15 -0
- package/scenario/bundle.d.ts.map +1 -1
- package/scenario/soundtrack.d.ts +46 -0
- package/scenario/soundtrack.d.ts.map +1 -0
- package/scenario/soundtrack.js +11 -0
- package/scenario/soundtrack.js.map +1 -0
- package/scenario/title-cards.d.ts +64 -0
- package/scenario/title-cards.d.ts.map +1 -0
- package/scenario/title-cards.js +20 -0
- package/scenario/title-cards.js.map +1 -0
- package/scenario/types.d.ts +36 -2
- package/scenario/types.d.ts.map +1 -1
- package/src/index.ts +32 -1
- package/src/presenter/derive-presenter-timeline.test.ts +138 -0
- package/src/presenter/derive-presenter-timeline.ts +54 -0
- package/src/presenter/presenter-opacity.test.ts +47 -0
- package/src/presenter/presenter-opacity.ts +43 -0
- package/src/presenter/types.ts +46 -0
- package/src/scenario/apply-title-cards.test.ts +268 -0
- package/src/scenario/apply-title-cards.ts +141 -0
- package/src/scenario/bundle.ts +15 -0
- package/src/scenario/soundtrack.ts +47 -0
- package/src/scenario/title-cards.ts +67 -0
- package/src/scenario/types.ts +36 -2
- package/src/timeline/compute-step-timeline.test.ts +21 -0
- package/src/timeline/compute-step-timeline.ts +30 -7
- package/src/timeline/derive-action-events.test.ts +144 -0
- package/src/timeline/derive-action-events.ts +116 -0
- package/src/timeline/derive-sfx-timeline.test.ts +114 -0
- package/src/timeline/derive-sfx-timeline.ts +79 -0
- package/src/timeline/music-envelope.test.ts +141 -0
- package/src/timeline/music-envelope.ts +131 -0
- package/timeline/compute-step-timeline.d.ts +28 -3
- package/timeline/compute-step-timeline.d.ts.map +1 -1
- package/timeline/compute-step-timeline.js +11 -3
- package/timeline/compute-step-timeline.js.map +1 -1
- package/timeline/derive-action-events.d.ts +40 -0
- package/timeline/derive-action-events.d.ts.map +1 -0
- package/timeline/derive-action-events.js +71 -0
- package/timeline/derive-action-events.js.map +1 -0
- package/timeline/derive-sfx-timeline.d.ts +36 -0
- package/timeline/derive-sfx-timeline.d.ts.map +1 -0
- package/timeline/derive-sfx-timeline.js +49 -0
- package/timeline/derive-sfx-timeline.js.map +1 -0
- package/timeline/music-envelope.d.ts +69 -0
- package/timeline/music-envelope.d.ts.map +1 -0
- package/timeline/music-envelope.js +95 -0
- 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:
|
|
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,
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
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":"
|
|
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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"}
|