@scenar/core 0.10.0 → 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.
- 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
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
import type { NarrationManifest } from "../narration/types.js";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Minimal step shape for timeline computation. Accepts any
|
|
5
|
-
* 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).
|
|
6
6
|
*/
|
|
7
7
|
interface StepTiming {
|
|
8
8
|
delayMs: number;
|
|
9
|
+
card?: { durationMs?: number };
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Minimal manifest shape for timeline computation: any positional
|
|
14
|
+
* manifest whose entries carry a duration. Accepts the narration
|
|
15
|
+
* manifest, and — while the player is muted with a presenter track —
|
|
16
|
+
* the presenter manifest, whose clip durations equal the narration's
|
|
17
|
+
* for the same steps (the clip is derived from that audio). Feeding
|
|
18
|
+
* the presenter manifest to the muted timeline keeps the progress
|
|
19
|
+
* bar, scrubbing, and step advancement in agreement on presenter
|
|
20
|
+
* steps, and converges muted timing on the export timeline there.
|
|
21
|
+
*/
|
|
22
|
+
interface ManifestTiming {
|
|
23
|
+
readonly steps: readonly ({ readonly durationMs: number } | null | undefined)[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dwell time on the final step so viewers can absorb the result.
|
|
28
|
+
* Also the delay `applyTitleCards` gives a synthesized outro card, so
|
|
29
|
+
* the last authored step keeps exactly this dwell before the card.
|
|
30
|
+
*/
|
|
31
|
+
export const FINAL_DWELL_MS = 3_000;
|
|
13
32
|
|
|
14
33
|
export interface StepTimeline {
|
|
15
34
|
/** Start time of each step in milliseconds (index 0 is always 0). */
|
|
@@ -30,7 +49,7 @@ export interface StepTimeline {
|
|
|
30
49
|
*/
|
|
31
50
|
export function computeStepTimeline(
|
|
32
51
|
steps: readonly StepTiming[],
|
|
33
|
-
manifest:
|
|
52
|
+
manifest: ManifestTiming | null | undefined,
|
|
34
53
|
): StepTimeline {
|
|
35
54
|
const stepStartTimesMs: number[] = [0];
|
|
36
55
|
|
|
@@ -43,7 +62,11 @@ export function computeStepTimeline(
|
|
|
43
62
|
|
|
44
63
|
const lastStepStart = stepStartTimesMs[stepStartTimesMs.length - 1] ?? 0;
|
|
45
64
|
const lastNarrationMs = manifest?.steps[steps.length - 1]?.durationMs ?? 0;
|
|
46
|
-
|
|
65
|
+
// A final card step (a synthesized outro) dwells for its configured
|
|
66
|
+
// duration instead of the fixed default — cards are silent, so the
|
|
67
|
+
// narration max is a no-op for them but kept for uniformity.
|
|
68
|
+
const closingDwellMs = steps[steps.length - 1]?.card?.durationMs ?? FINAL_DWELL_MS;
|
|
69
|
+
const totalDurationMs = lastStepStart + Math.max(closingDwellMs, lastNarrationMs);
|
|
47
70
|
|
|
48
71
|
return { stepStartTimesMs, totalDurationMs };
|
|
49
72
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { StepAction } from "../scenario/step-action.js";
|
|
3
|
+
import { deriveActionEvents, type ActionEvent } from "./derive-action-events.js";
|
|
4
|
+
import {
|
|
5
|
+
CLICK_DELAY_MS,
|
|
6
|
+
DRAG_SETTLE_MS,
|
|
7
|
+
HOVER_HOLD_MS,
|
|
8
|
+
TYPE_CHAR_DELAY_MS,
|
|
9
|
+
} from "../timing/constants.js";
|
|
10
|
+
|
|
11
|
+
const DURATION = 2000;
|
|
12
|
+
|
|
13
|
+
function offsets(events: ActionEvent[]): Record<string, number[]> {
|
|
14
|
+
const byKind: Record<string, number[]> = {};
|
|
15
|
+
for (const e of events) {
|
|
16
|
+
(byKind[e.kind] ??= []).push(e.offsetMs);
|
|
17
|
+
}
|
|
18
|
+
return byKind;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("deriveActionEvents", () => {
|
|
22
|
+
it("click: cursor move at fireAt, dispatch after the travel window", () => {
|
|
23
|
+
const action: StepAction = { atPercent: 0.5, type: "click", target: "btn" };
|
|
24
|
+
expect(offsets(deriveActionEvents(action, DURATION))).toEqual({
|
|
25
|
+
"cursor-move": [1000],
|
|
26
|
+
"click-dispatch": [1000 + CLICK_DELAY_MS],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("type: one keystroke per character at the typing cadence", () => {
|
|
31
|
+
const action: StepAction = { atPercent: 0, type: "type", target: "input", text: "abc" };
|
|
32
|
+
const events = deriveActionEvents(action, DURATION);
|
|
33
|
+
const keystrokes = events.filter((e) => e.kind === "keystroke");
|
|
34
|
+
|
|
35
|
+
expect(events[0]).toEqual({ kind: "cursor-move", offsetMs: 0 });
|
|
36
|
+
expect(keystrokes).toEqual([
|
|
37
|
+
{ kind: "keystroke", offsetMs: CLICK_DELAY_MS, charIndex: 0 },
|
|
38
|
+
{ kind: "keystroke", offsetMs: CLICK_DELAY_MS + TYPE_CHAR_DELAY_MS, charIndex: 1 },
|
|
39
|
+
{ kind: "keystroke", offsetMs: CLICK_DELAY_MS + 2 * TYPE_CHAR_DELAY_MS, charIndex: 2 },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("type: honors a per-action typeDelay override", () => {
|
|
44
|
+
const action: StepAction = {
|
|
45
|
+
atPercent: 0,
|
|
46
|
+
type: "type",
|
|
47
|
+
target: "input",
|
|
48
|
+
text: "ab",
|
|
49
|
+
typeDelay: 120,
|
|
50
|
+
};
|
|
51
|
+
const keystrokes = deriveActionEvents(action, DURATION).filter(
|
|
52
|
+
(e) => e.kind === "keystroke",
|
|
53
|
+
);
|
|
54
|
+
expect(keystrokes.map((e) => e.offsetMs)).toEqual([
|
|
55
|
+
CLICK_DELAY_MS,
|
|
56
|
+
CLICK_DELAY_MS + 120,
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("type: empty text is a no-op, exactly like the schedulers", () => {
|
|
61
|
+
const action: StepAction = { atPercent: 0.5, type: "type", target: "input", text: "" };
|
|
62
|
+
expect(deriveActionEvents(action, DURATION)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("type: unicode text counts UTF-16 code units, matching substring typing", () => {
|
|
66
|
+
// The schedulers type text.substring(0, i + 1) for i < text.length, so
|
|
67
|
+
// the derivation must count the same units — an emoji surrogate pair
|
|
68
|
+
// yields two keystrokes, one per code unit.
|
|
69
|
+
const action: StepAction = { atPercent: 0, type: "type", target: "input", text: "é🙂" };
|
|
70
|
+
const keystrokes = deriveActionEvents(action, DURATION).filter(
|
|
71
|
+
(e) => e.kind === "keystroke",
|
|
72
|
+
);
|
|
73
|
+
expect(keystrokes).toHaveLength("é🙂".length);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("hover: enter after travel, leave after the hold", () => {
|
|
77
|
+
const action: StepAction = { atPercent: 0.25, type: "hover", target: "tip" };
|
|
78
|
+
expect(offsets(deriveActionEvents(action, DURATION))).toEqual({
|
|
79
|
+
"cursor-move": [500],
|
|
80
|
+
"hover-enter": [500 + CLICK_DELAY_MS],
|
|
81
|
+
"hover-leave": [500 + CLICK_DELAY_MS + HOVER_HOLD_MS],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("hover: honors a per-action hoverDuration override", () => {
|
|
86
|
+
const action: StepAction = {
|
|
87
|
+
atPercent: 0,
|
|
88
|
+
type: "hover",
|
|
89
|
+
target: "tip",
|
|
90
|
+
hoverDuration: 200,
|
|
91
|
+
};
|
|
92
|
+
const leave = deriveActionEvents(action, DURATION).find((e) => e.kind === "hover-leave");
|
|
93
|
+
expect(leave?.offsetMs).toBe(CLICK_DELAY_MS + 200);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("drag: press, move, release along the settle chain", () => {
|
|
97
|
+
const action: StepAction = {
|
|
98
|
+
atPercent: 0.1,
|
|
99
|
+
type: "drag",
|
|
100
|
+
target: "card",
|
|
101
|
+
dragTarget: "column",
|
|
102
|
+
};
|
|
103
|
+
expect(offsets(deriveActionEvents(action, DURATION))).toEqual({
|
|
104
|
+
"cursor-move": [200],
|
|
105
|
+
"drag-press": [200 + CLICK_DELAY_MS],
|
|
106
|
+
"drag-move": [200 + CLICK_DELAY_MS + DRAG_SETTLE_MS],
|
|
107
|
+
"drag-release": [200 + CLICK_DELAY_MS + DRAG_SETTLE_MS + CLICK_DELAY_MS],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.each([
|
|
112
|
+
["scroll_to", "list"],
|
|
113
|
+
["set_cursor", "btn"],
|
|
114
|
+
["clear_cursor", undefined],
|
|
115
|
+
] as const)("%s: a single dispatch at fireAt", (type, target) => {
|
|
116
|
+
const action: StepAction = { atPercent: 0.5, type, target };
|
|
117
|
+
expect(deriveActionEvents(action, DURATION)).toEqual([
|
|
118
|
+
{ kind: "simple-dispatch", offsetMs: 1000 },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("viewport_transition: fires at fireAt with no travel delay", () => {
|
|
123
|
+
const action: StepAction = { atPercent: 0.5, type: "viewport_transition", target: "hero" };
|
|
124
|
+
expect(deriveActionEvents(action, DURATION)).toEqual([
|
|
125
|
+
{ kind: "viewport-transition", offsetMs: 1000 },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.each([
|
|
130
|
+
["atPercent 0.0 anchors at step start", 0.0, 0],
|
|
131
|
+
["atPercent 1.0 anchors at step end", 1.0, DURATION],
|
|
132
|
+
])("%s", (_name, atPercent, expected) => {
|
|
133
|
+
const action: StepAction = { atPercent, type: "click", target: "btn" };
|
|
134
|
+
expect(deriveActionEvents(action, DURATION)[0]?.offsetMs).toBe(expected);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("zero-duration step: every anchor collapses to the fixed offsets", () => {
|
|
138
|
+
const action: StepAction = { atPercent: 0.7, type: "click", target: "btn" };
|
|
139
|
+
expect(offsets(deriveActionEvents(action, 0))).toEqual({
|
|
140
|
+
"cursor-move": [0],
|
|
141
|
+
"click-dispatch": [CLICK_DELAY_MS],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -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
|
+
});
|