@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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { NarrationManifest } from "../narration/types.js";
|
|
3
|
+
import type { PresenterManifest } from "../presenter/types.js";
|
|
4
|
+
import { FINAL_DWELL_MS, computeStepTimeline } from "../timeline/compute-step-timeline.js";
|
|
5
|
+
import { deriveStepFromTime } from "../timeline/derive-step.js";
|
|
6
|
+
import { applyTitleCards } from "./apply-title-cards.js";
|
|
7
|
+
import { TITLE_CARD_DURATION_DEFAULT_MS } from "./title-cards.js";
|
|
8
|
+
import type { ScenarioStep } from "./types.js";
|
|
9
|
+
|
|
10
|
+
interface Data {
|
|
11
|
+
screen: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const step = (delayMs: number, screen: string): ScenarioStep<Data> => ({
|
|
15
|
+
delayMs,
|
|
16
|
+
data: { screen },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const authored: ScenarioStep<Data>[] = [step(0, "login"), step(1500, "dashboard")];
|
|
20
|
+
|
|
21
|
+
const manifest: NarrationManifest = {
|
|
22
|
+
steps: [
|
|
23
|
+
{ src: "./step-0.mp3", durationMs: 2000 },
|
|
24
|
+
{ src: "./step-1.mp3", durationMs: 1200 },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("applyTitleCards", () => {
|
|
29
|
+
describe("no-op cases", () => {
|
|
30
|
+
it("returns the inputs untouched when titleCards is undefined", () => {
|
|
31
|
+
const result = applyTitleCards(authored, manifest, undefined);
|
|
32
|
+
expect(result.steps).toBe(authored);
|
|
33
|
+
expect(result.narrationManifest).toBe(manifest);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns the inputs untouched when neither intro nor outro is set", () => {
|
|
37
|
+
const result = applyTitleCards(authored, manifest, {});
|
|
38
|
+
expect(result.steps).toBe(authored);
|
|
39
|
+
expect(result.narrationManifest).toBe(manifest);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("intro", () => {
|
|
44
|
+
it("prepends an intro card step with the card content", () => {
|
|
45
|
+
const { steps } = applyTitleCards(authored, undefined, {
|
|
46
|
+
intro: { title: "Acme", subtitle: "Ship fast", logoSrc: "./logo.png" },
|
|
47
|
+
});
|
|
48
|
+
expect(steps).toHaveLength(3);
|
|
49
|
+
expect(steps[0]!.delayMs).toBe(0);
|
|
50
|
+
expect(steps[0]!.card).toEqual({
|
|
51
|
+
kind: "intro",
|
|
52
|
+
title: "Acme",
|
|
53
|
+
subtitle: "Ship fast",
|
|
54
|
+
logoSrc: "./logo.png",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("encodes the card duration as the following step's transition delay", () => {
|
|
59
|
+
const { steps } = applyTitleCards(authored, undefined, {
|
|
60
|
+
intro: { title: "Acme", durationMs: 4500 },
|
|
61
|
+
});
|
|
62
|
+
expect(steps[1]!.delayMs).toBe(4500);
|
|
63
|
+
expect(steps[1]!.data).toEqual({ screen: "login" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("defaults the card duration when unset", () => {
|
|
67
|
+
const { steps } = applyTitleCards(authored, undefined, { intro: { title: "Acme" } });
|
|
68
|
+
expect(steps[1]!.delayMs).toBe(TITLE_CARD_DURATION_DEFAULT_MS);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps a larger authored first-step delay over the card duration", () => {
|
|
72
|
+
const slowStart = [step(8000, "login"), step(1500, "dashboard")];
|
|
73
|
+
const { steps } = applyTitleCards(slowStart, undefined, {
|
|
74
|
+
intro: { title: "Acme", durationMs: 3000 },
|
|
75
|
+
});
|
|
76
|
+
expect(steps[1]!.delayMs).toBe(8000);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("pads the narration manifest with a leading null", () => {
|
|
80
|
+
const { narrationManifest } = applyTitleCards(authored, manifest, {
|
|
81
|
+
intro: { title: "Acme" },
|
|
82
|
+
});
|
|
83
|
+
expect(narrationManifest!.steps).toEqual([null, ...manifest.steps]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves an absent manifest absent", () => {
|
|
87
|
+
const { narrationManifest } = applyTitleCards(authored, undefined, {
|
|
88
|
+
intro: { title: "Acme" },
|
|
89
|
+
});
|
|
90
|
+
expect(narrationManifest).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not prepend a card to an empty steps array", () => {
|
|
94
|
+
const { steps } = applyTitleCards<Data>([], undefined, { intro: { title: "Acme" } });
|
|
95
|
+
expect(steps).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("outro", () => {
|
|
100
|
+
it("appends an outro card step that preserves the last authored step's dwell", () => {
|
|
101
|
+
const { steps } = applyTitleCards(authored, undefined, {
|
|
102
|
+
outro: { title: "Try it", ctaText: "acme.dev" },
|
|
103
|
+
});
|
|
104
|
+
expect(steps).toHaveLength(3);
|
|
105
|
+
const outro = steps[2]!;
|
|
106
|
+
expect(outro.delayMs).toBe(FINAL_DWELL_MS);
|
|
107
|
+
expect(outro.card).toEqual({ kind: "outro", title: "Try it", ctaText: "acme.dev" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("clears the cursor and resets the viewport at card entry", () => {
|
|
111
|
+
const { steps } = applyTitleCards(authored, undefined, { outro: { title: "Try it" } });
|
|
112
|
+
expect(steps[2]!.interactions).toEqual([
|
|
113
|
+
{ atPercent: 0, type: "clear_cursor" },
|
|
114
|
+
{ atPercent: 0, type: "viewport_transition", viewportReset: true },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("pads the narration manifest with a trailing null", () => {
|
|
119
|
+
const { narrationManifest } = applyTitleCards(authored, manifest, {
|
|
120
|
+
outro: { title: "Try it" },
|
|
121
|
+
});
|
|
122
|
+
expect(narrationManifest!.steps).toEqual([...manifest.steps, null]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("intro and outro together", () => {
|
|
127
|
+
it("frames the authored steps and pads the manifest on both ends", () => {
|
|
128
|
+
const { steps, narrationManifest } = applyTitleCards(authored, manifest, {
|
|
129
|
+
intro: { title: "Acme" },
|
|
130
|
+
outro: { title: "Try it" },
|
|
131
|
+
});
|
|
132
|
+
expect(steps).toHaveLength(4);
|
|
133
|
+
expect(steps[0]!.card?.kind).toBe("intro");
|
|
134
|
+
expect(steps[1]!.data).toEqual({ screen: "login" });
|
|
135
|
+
expect(steps[2]!.data).toEqual({ screen: "dashboard" });
|
|
136
|
+
expect(steps[3]!.card?.kind).toBe("outro");
|
|
137
|
+
expect(narrationManifest!.steps).toEqual([null, ...manifest.steps, null]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("copies only known card fields from a loosely-typed config", () => {
|
|
141
|
+
const stray = {
|
|
142
|
+
title: "Acme",
|
|
143
|
+
clickHandler: () => {},
|
|
144
|
+
} as unknown as { title: string };
|
|
145
|
+
const { steps } = applyTitleCards(authored, undefined, { intro: stray });
|
|
146
|
+
expect(steps[0]!.card).toEqual({ kind: "intro", title: "Acme" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not mutate the input steps or manifest", () => {
|
|
150
|
+
const stepsBefore = [...authored];
|
|
151
|
+
const manifestBefore = [...manifest.steps];
|
|
152
|
+
applyTitleCards(authored, manifest, {
|
|
153
|
+
intro: { title: "Acme" },
|
|
154
|
+
outro: { title: "Try it" },
|
|
155
|
+
});
|
|
156
|
+
expect(authored).toEqual(stepsBefore);
|
|
157
|
+
expect(manifest.steps).toEqual(manifestBefore);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("presenter manifest padding", () => {
|
|
162
|
+
const presenterManifest: PresenterManifest = {
|
|
163
|
+
steps: [{ src: "./step-0.mp4", durationMs: 2000 }, null],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
it("pads narration and presenter manifests at identical positions", () => {
|
|
167
|
+
const result = applyTitleCards(authored, manifest, {
|
|
168
|
+
intro: { title: "Acme" },
|
|
169
|
+
outro: { title: "Try it" },
|
|
170
|
+
}, presenterManifest);
|
|
171
|
+
|
|
172
|
+
expect(result.narrationManifest!.steps).toEqual([null, ...manifest.steps, null]);
|
|
173
|
+
expect(result.presenterManifest!.steps).toEqual([
|
|
174
|
+
null,
|
|
175
|
+
...presenterManifest.steps,
|
|
176
|
+
null,
|
|
177
|
+
]);
|
|
178
|
+
// Index-aligned by construction: same length as the expanded steps.
|
|
179
|
+
expect(result.presenterManifest!.steps).toHaveLength(result.steps.length);
|
|
180
|
+
expect(result.narrationManifest!.steps).toHaveLength(result.steps.length);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns the presenter manifest untouched when no card is configured", () => {
|
|
184
|
+
const result = applyTitleCards(authored, manifest, undefined, presenterManifest);
|
|
185
|
+
expect(result.presenterManifest).toBe(presenterManifest);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("leaves an absent presenter manifest absent", () => {
|
|
189
|
+
const result = applyTitleCards(authored, manifest, { intro: { title: "Acme" } });
|
|
190
|
+
expect(result.presenterManifest).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("pads the presenter manifest even when narration is absent", () => {
|
|
194
|
+
const result = applyTitleCards(authored, undefined, {
|
|
195
|
+
outro: { title: "Try it" },
|
|
196
|
+
}, presenterManifest);
|
|
197
|
+
expect(result.narrationManifest).toBeUndefined();
|
|
198
|
+
expect(result.presenterManifest!.steps).toEqual([...presenterManifest.steps, null]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not mutate the input presenter manifest", () => {
|
|
202
|
+
const entriesBefore = [...presenterManifest.steps];
|
|
203
|
+
applyTitleCards(authored, manifest, {
|
|
204
|
+
intro: { title: "Acme" },
|
|
205
|
+
outro: { title: "Try it" },
|
|
206
|
+
}, presenterManifest);
|
|
207
|
+
expect(presenterManifest.steps).toEqual(entriesBefore);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("timeline integration", () => {
|
|
212
|
+
it("gives the intro exactly its duration and the outro exactly its dwell", () => {
|
|
213
|
+
const { steps, narrationManifest } = applyTitleCards(authored, manifest, {
|
|
214
|
+
intro: { title: "Acme", durationMs: 4000 },
|
|
215
|
+
outro: { title: "Try it", durationMs: 5000 },
|
|
216
|
+
});
|
|
217
|
+
const tl = computeStepTimeline(steps, narrationManifest);
|
|
218
|
+
// Intro visible 0..4000 (card duration beats step-0 narration? No —
|
|
219
|
+
// the manifest is padded, so the intro has no narration entry).
|
|
220
|
+
expect(tl.stepStartTimesMs).toEqual([
|
|
221
|
+
0,
|
|
222
|
+
4000, // login enters after the intro's 4000ms
|
|
223
|
+
4000 + 2000, // dashboard after max(1500, login narration 2000)
|
|
224
|
+
6000 + Math.max(FINAL_DWELL_MS, 1200), // outro after the authored closing dwell
|
|
225
|
+
]);
|
|
226
|
+
// The outro dwells for its configured 5000ms.
|
|
227
|
+
expect(tl.totalDurationMs).toBe(9000 + 5000);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("changes nothing about the authored steps' relative timing", () => {
|
|
231
|
+
const bare = computeStepTimeline(authored, manifest);
|
|
232
|
+
const { steps, narrationManifest } = applyTitleCards(authored, manifest, {
|
|
233
|
+
intro: { title: "Acme" },
|
|
234
|
+
});
|
|
235
|
+
const framed = computeStepTimeline(steps, narrationManifest);
|
|
236
|
+
// Every authored boundary shifts by exactly the intro duration.
|
|
237
|
+
const shifted = bare.stepStartTimesMs.map((ms) => ms + TITLE_CARD_DURATION_DEFAULT_MS);
|
|
238
|
+
expect(framed.stepStartTimesMs.slice(1)).toEqual(shifted);
|
|
239
|
+
expect(framed.totalDurationMs).toBe(bare.totalDurationMs + TITLE_CARD_DURATION_DEFAULT_MS);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("derives the same active step in both time domains across card boundaries", () => {
|
|
243
|
+
const fps = 30;
|
|
244
|
+
const msToFrame = (ms: number) => Math.round((ms * fps) / 1000);
|
|
245
|
+
const frameToMs = (frame: number) => (frame * 1000) / fps;
|
|
246
|
+
|
|
247
|
+
const { steps, narrationManifest } = applyTitleCards(authored, manifest, {
|
|
248
|
+
intro: { title: "Acme" },
|
|
249
|
+
outro: { title: "Try it" },
|
|
250
|
+
});
|
|
251
|
+
const tl = computeStepTimeline(steps, narrationManifest);
|
|
252
|
+
const lastIndex = steps.length - 1;
|
|
253
|
+
|
|
254
|
+
// Sample densely around every boundary plus mid-step points.
|
|
255
|
+
const samples = tl.stepStartTimesMs.flatMap((ms) => [ms - 34, ms, ms + 34]);
|
|
256
|
+
samples.push(tl.totalDurationMs - 34, tl.totalDurationMs);
|
|
257
|
+
for (const t of samples.filter((ms) => ms >= 0)) {
|
|
258
|
+
const browserStep = deriveStepFromTime(t, tl.stepStartTimesMs, lastIndex);
|
|
259
|
+
const frameStep = deriveStepFromTime(
|
|
260
|
+
frameToMs(msToFrame(t)),
|
|
261
|
+
tl.stepStartTimesMs,
|
|
262
|
+
lastIndex,
|
|
263
|
+
);
|
|
264
|
+
expect(frameStep).toBe(browserStep);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card synthesis: expand a scenario's steps (and narration manifest)
|
|
3
|
+
* with its configured intro/outro title cards.
|
|
4
|
+
*
|
|
5
|
+
* This is THE one place card steps are constructed. It runs exactly
|
|
6
|
+
* once per playback surface, at bundle assembly — the generated render
|
|
7
|
+
* and pack entries, the CLI's `loadBundle`, and (for direct React
|
|
8
|
+
* integrators) one documented call between `createScenario()` and the
|
|
9
|
+
* player. Authoring surfaces (`createScenario`, `loadScenarioFromProto`)
|
|
10
|
+
* carry the `titleCards` config through untouched, exactly like
|
|
11
|
+
* `soundtrack` — so expanded output never carries the config and double
|
|
12
|
+
* expansion is impossible by data flow.
|
|
13
|
+
*
|
|
14
|
+
* The positional manifests stay keyed to AUTHORED steps everywhere
|
|
15
|
+
* (`scenar narrate` and `scenar presenter` never see cards); this
|
|
16
|
+
* function pads `null` entries at the injected positions — narration
|
|
17
|
+
* and presenter in the same single pass — so the expanded manifests
|
|
18
|
+
* line up with the expanded steps. Existing manifests therefore stay
|
|
19
|
+
* valid when an author adds cards later — no regeneration required.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { NarrationManifest } from "../narration/types.js";
|
|
23
|
+
import type { PresenterManifest } from "../presenter/types.js";
|
|
24
|
+
import { FINAL_DWELL_MS } from "../timeline/compute-step-timeline.js";
|
|
25
|
+
import type { ScenarioStep } from "./types.js";
|
|
26
|
+
import {
|
|
27
|
+
TITLE_CARD_DURATION_DEFAULT_MS,
|
|
28
|
+
type TitleCard,
|
|
29
|
+
type TitleCards,
|
|
30
|
+
} from "./title-cards.js";
|
|
31
|
+
|
|
32
|
+
/** The result of card expansion: steps and manifests, index-aligned. */
|
|
33
|
+
export interface AppliedTitleCards<T> {
|
|
34
|
+
readonly steps: readonly ScenarioStep<T>[];
|
|
35
|
+
readonly narrationManifest: NarrationManifest | undefined;
|
|
36
|
+
readonly presenterManifest: PresenterManifest | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A card step's `data` placeholder. Card steps are rendered by the
|
|
41
|
+
* player's built-in card component — the integrator's render callback
|
|
42
|
+
* (the only reader of `data`) is never invoked for them, so no real
|
|
43
|
+
* `T` value is ever needed. See the `ScenarioStep.card` doc contract.
|
|
44
|
+
*/
|
|
45
|
+
function cardStepData<T>(): T {
|
|
46
|
+
return undefined as unknown as T;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Expand `steps` (and the index-parallel narration and presenter
|
|
51
|
+
* manifests, when they exist) with the configured intro/outro cards.
|
|
52
|
+
*
|
|
53
|
+
* - **Intro**: prepended at index 0 with `delayMs: 0`; the card's
|
|
54
|
+
* visible time is encoded as the following step's transition delay
|
|
55
|
+
* (`max(authored delayMs, card duration)`), which is exactly how
|
|
56
|
+
* `computeStepTimeline` reads step 0's duration.
|
|
57
|
+
* - **Outro**: appended with `delayMs: FINAL_DWELL_MS`, so the last
|
|
58
|
+
* authored step keeps precisely the closing dwell it has today (or
|
|
59
|
+
* its narration, whichever is longer) before the card appears. The
|
|
60
|
+
* card's own visible time is its `durationMs` — `computeStepTimeline`
|
|
61
|
+
* reads a final card step's duration as the closing dwell. The outro
|
|
62
|
+
* carries two housekeeping interactions at step entry (`clear_cursor`
|
|
63
|
+
* and a viewport reset) so a scenario ending with a visible cursor or
|
|
64
|
+
* an active zoom never leaks that state onto the card. Neither action
|
|
65
|
+
* maps to a sound effect.
|
|
66
|
+
*
|
|
67
|
+
* Pure and non-mutating. When `titleCards` configures no card, the
|
|
68
|
+
* inputs are returned as-is (byte-identical no-op).
|
|
69
|
+
*/
|
|
70
|
+
export function applyTitleCards<T>(
|
|
71
|
+
steps: readonly ScenarioStep<T>[],
|
|
72
|
+
narrationManifest: NarrationManifest | undefined,
|
|
73
|
+
titleCards: TitleCards | undefined,
|
|
74
|
+
presenterManifest?: PresenterManifest,
|
|
75
|
+
): AppliedTitleCards<T> {
|
|
76
|
+
const intro = titleCards?.intro;
|
|
77
|
+
const outro = titleCards?.outro;
|
|
78
|
+
if (!intro && !outro) {
|
|
79
|
+
return { steps, narrationManifest, presenterManifest };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const expandedSteps: ScenarioStep<T>[] = [...steps];
|
|
83
|
+
let manifestEntries = narrationManifest ? [...narrationManifest.steps] : undefined;
|
|
84
|
+
let presenterEntries = presenterManifest ? [...presenterManifest.steps] : undefined;
|
|
85
|
+
|
|
86
|
+
if (intro && expandedSteps.length > 0) {
|
|
87
|
+
const introDurationMs = intro.durationMs ?? TITLE_CARD_DURATION_DEFAULT_MS;
|
|
88
|
+
const firstAuthored = expandedSteps[0]!;
|
|
89
|
+
expandedSteps[0] = {
|
|
90
|
+
...firstAuthored,
|
|
91
|
+
delayMs: Math.max(firstAuthored.delayMs, introDurationMs),
|
|
92
|
+
};
|
|
93
|
+
expandedSteps.unshift({
|
|
94
|
+
delayMs: 0,
|
|
95
|
+
data: cardStepData<T>(),
|
|
96
|
+
card: { kind: "intro", ...pickCardContent(intro) },
|
|
97
|
+
});
|
|
98
|
+
manifestEntries?.unshift(null);
|
|
99
|
+
presenterEntries?.unshift(null);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (outro) {
|
|
103
|
+
expandedSteps.push({
|
|
104
|
+
delayMs: FINAL_DWELL_MS,
|
|
105
|
+
data: cardStepData<T>(),
|
|
106
|
+
card: { kind: "outro", ...pickCardContent(outro) },
|
|
107
|
+
interactions: [
|
|
108
|
+
{ atPercent: 0, type: "clear_cursor" },
|
|
109
|
+
{ atPercent: 0, type: "viewport_transition", viewportReset: true },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
manifestEntries?.push(null);
|
|
113
|
+
presenterEntries?.push(null);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
steps: expandedSteps,
|
|
118
|
+
narrationManifest: manifestEntries ? { steps: manifestEntries } : undefined,
|
|
119
|
+
presenterManifest: presenterEntries ? { steps: presenterEntries } : undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Copy only the known card fields, so a config object carrying strays
|
|
125
|
+
* (e.g. a loosely-typed steps.ts export) never smuggles them into the
|
|
126
|
+
* step list.
|
|
127
|
+
*/
|
|
128
|
+
function pickCardContent(card: TitleCard): TitleCard {
|
|
129
|
+
const content: {
|
|
130
|
+
title: string;
|
|
131
|
+
subtitle?: string;
|
|
132
|
+
logoSrc?: string;
|
|
133
|
+
ctaText?: string;
|
|
134
|
+
durationMs?: number;
|
|
135
|
+
} = { title: card.title };
|
|
136
|
+
if (card.subtitle !== undefined) content.subtitle = card.subtitle;
|
|
137
|
+
if (card.logoSrc !== undefined) content.logoSrc = card.logoSrc;
|
|
138
|
+
if (card.ctaText !== undefined) content.ctaText = card.ctaText;
|
|
139
|
+
if (card.durationMs !== undefined) content.durationMs = card.durationMs;
|
|
140
|
+
return content;
|
|
141
|
+
}
|
package/src/scenario/bundle.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { NarrationManifest } from "../narration/types.js";
|
|
11
|
+
import type { PresenterManifest } from "../presenter/types.js";
|
|
12
|
+
import type { Soundtrack } from "./soundtrack.js";
|
|
11
13
|
import type { ScenarioStep } from "./types.js";
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -26,4 +28,17 @@ export interface ScenarioBundle<T> {
|
|
|
26
28
|
* narration (timing is purely delay-based).
|
|
27
29
|
*/
|
|
28
30
|
readonly narrationManifest?: NarrationManifest;
|
|
31
|
+
/**
|
|
32
|
+
* Audio treatment: background music with narration ducking and
|
|
33
|
+
* interaction sound effects. When `undefined`, the scenario plays
|
|
34
|
+
* silent apart from narration.
|
|
35
|
+
*/
|
|
36
|
+
readonly soundtrack?: Soundtrack;
|
|
37
|
+
/**
|
|
38
|
+
* Pre-built presenter manifest mapping step indices to avatar clip
|
|
39
|
+
* URLs and durations (generated by `scenar presenter`). When
|
|
40
|
+
* `undefined`, the scenario plays without the presenter — zero
|
|
41
|
+
* presenter DOM, zero fetched bytes.
|
|
42
|
+
*/
|
|
43
|
+
readonly presenterManifest?: PresenterManifest;
|
|
29
44
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scenario's audio treatment beyond narration: optional background
|
|
3
|
+
* music with narration ducking, and optional interaction sound effects.
|
|
4
|
+
*
|
|
5
|
+
* Runtime mirror of the proto `SoundtrackConfig` (`ai.scenar.scenario.v1`),
|
|
6
|
+
* field for field, in camelCase — the same 1:1 relationship `StepAction`
|
|
7
|
+
* has with its proto message. Music and sound effects are independent:
|
|
8
|
+
* a scenario may use either without the other.
|
|
9
|
+
*/
|
|
10
|
+
export interface Soundtrack {
|
|
11
|
+
/**
|
|
12
|
+
* Background music asset reference. Like narration clip srcs, a
|
|
13
|
+
* relative path resolves against the scenario's own location; an
|
|
14
|
+
* absolute URL passes through unchanged. MP3 is the supported format.
|
|
15
|
+
* When `undefined`, the scenario has no music.
|
|
16
|
+
*/
|
|
17
|
+
readonly musicSrc?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Base music level while no narration is playing (0–1).
|
|
20
|
+
* Defaults to {@link MUSIC_VOLUME_DEFAULT}.
|
|
21
|
+
*/
|
|
22
|
+
readonly musicVolume?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Absolute music level while a narration clip plays (0–1) — the level
|
|
25
|
+
* the music ducks to, not a multiplier of {@link musicVolume}.
|
|
26
|
+
* Defaults to {@link DUCKING_VOLUME_DEFAULT}.
|
|
27
|
+
*/
|
|
28
|
+
readonly duckingVolume?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Enables the engine's built-in interaction sound effects (click and
|
|
31
|
+
* keystroke). Requires an explicit `true` — adding music alone never
|
|
32
|
+
* introduces sound effects.
|
|
33
|
+
*/
|
|
34
|
+
readonly sfx?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default base music level. Clearly audible under silence without
|
|
39
|
+
* competing with interface sound effects.
|
|
40
|
+
*/
|
|
41
|
+
export const MUSIC_VOLUME_DEFAULT = 0.25;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default ducked music level while narration plays. Present but firmly
|
|
45
|
+
* under the voice.
|
|
46
|
+
*/
|
|
47
|
+
export const DUCKING_VOLUME_DEFAULT = 0.08;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intro and outro title cards framing a scenario.
|
|
3
|
+
*
|
|
4
|
+
* Runtime mirror of the proto `TitleCardsConfig` / `TitleCard`
|
|
5
|
+
* (`ai.scenar.scenario.v1`), field for field, in camelCase — the same
|
|
6
|
+
* 1:1 relationship `Soundtrack` has with its proto message.
|
|
7
|
+
*
|
|
8
|
+
* Cards are synthesized steps: {@link applyTitleCards} injects them
|
|
9
|
+
* into the step list at bundle assembly, so chapter markers, scrubbing,
|
|
10
|
+
* video duration, and the music envelope account for them with no
|
|
11
|
+
* card-specific timing code. The player renders card steps with its
|
|
12
|
+
* built-in card component — the scenario's render function is never
|
|
13
|
+
* called for them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One card's content — deliberately small; a card, not a page builder. */
|
|
17
|
+
export interface TitleCard {
|
|
18
|
+
/** Headline text. The one required field. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
/** Supporting line rendered under the title. */
|
|
21
|
+
readonly subtitle?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Logo image asset reference. Like narration and music srcs, a
|
|
24
|
+
* relative path resolves against the scenario's own location; an
|
|
25
|
+
* absolute URL passes through unchanged. Raster web formats only
|
|
26
|
+
* (png, jpg/jpeg, gif, webp, avif) — the deploy contract excludes
|
|
27
|
+
* svg as active content.
|
|
28
|
+
*/
|
|
29
|
+
readonly logoSrc?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Call-to-action text rendered as a distinct pill. Display-only —
|
|
32
|
+
* not a link. Typically used on the outro.
|
|
33
|
+
*/
|
|
34
|
+
readonly ctaText?: string;
|
|
35
|
+
/**
|
|
36
|
+
* How long the card stays on screen, in milliseconds.
|
|
37
|
+
* Defaults to {@link TITLE_CARD_DURATION_DEFAULT_MS}.
|
|
38
|
+
*/
|
|
39
|
+
readonly durationMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The scenario-level card configuration: an intro card, an outro card,
|
|
44
|
+
* or both. Either field may be set independently; neither set is a no-op.
|
|
45
|
+
*/
|
|
46
|
+
export interface TitleCards {
|
|
47
|
+
/** Opening card shown before the first authored step. */
|
|
48
|
+
readonly intro?: TitleCard;
|
|
49
|
+
/** Closing card shown after the last authored step. */
|
|
50
|
+
readonly outro?: TitleCard;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The card marker carried by a synthesized card step
|
|
55
|
+
* ({@link ScenarioStep.card}). `kind` records which side of the
|
|
56
|
+
* scenario the card frames so the renderer can style intro and outro
|
|
57
|
+
* distinctly if it chooses.
|
|
58
|
+
*/
|
|
59
|
+
export interface StepCard extends TitleCard {
|
|
60
|
+
readonly kind: "intro" | "outro";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Default card visible time. Long enough to read a title and subtitle,
|
|
65
|
+
* short enough to not delay the content.
|
|
66
|
+
*/
|
|
67
|
+
export const TITLE_CARD_DURATION_DEFAULT_MS = 3_000;
|
package/src/scenario/types.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { StepAction } from "./step-action.js";
|
|
9
|
+
import type { StepCard } from "./title-cards.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A single step in a scenario timeline.
|
|
@@ -18,8 +19,12 @@ export interface ScenarioStep<T> {
|
|
|
18
19
|
/** Data snapshot at this point in the timeline. */
|
|
19
20
|
readonly data: T;
|
|
20
21
|
/**
|
|
21
|
-
* Narration script for
|
|
22
|
-
*
|
|
22
|
+
* Narration script for this step. Consumed by `scenar narrate` to
|
|
23
|
+
* produce audio files, and rendered at runtime as the step's caption
|
|
24
|
+
* when the player has captions enabled (a presentation preference —
|
|
25
|
+
* the `captions` player prop, the `?captions=1` embed param, or
|
|
26
|
+
* `scenar render --captions`). Steps without narration play
|
|
27
|
+
* uncaptioned.
|
|
23
28
|
*/
|
|
24
29
|
readonly narration?: string;
|
|
25
30
|
/**
|
|
@@ -31,6 +36,22 @@ export interface ScenarioStep<T> {
|
|
|
31
36
|
* share the same atPercent, they fire in array order.
|
|
32
37
|
*/
|
|
33
38
|
readonly interactions?: readonly StepAction[];
|
|
39
|
+
/**
|
|
40
|
+
* Marks this step for the presenter track. When true, `scenar presenter`
|
|
41
|
+
* generates an avatar clip lip-synced to this step's narration audio,
|
|
42
|
+
* and both outputs show the clip picture-in-picture while the step is
|
|
43
|
+
* active — the interactive embed and the exported video alike.
|
|
44
|
+
*
|
|
45
|
+
* Requires `narration`: the presenter clip is derived from the step's
|
|
46
|
+
* narration audio by definition, so a step without narration cannot
|
|
47
|
+
* opt in (validated at load time).
|
|
48
|
+
*
|
|
49
|
+
* Playback never calls an AI service. Like narration audio, presenter
|
|
50
|
+
* clips are generated at compile time by the CLI and consumed as fixed
|
|
51
|
+
* assets through a positional manifest; a scenario whose clips have
|
|
52
|
+
* not been generated yet simply plays without the presenter.
|
|
53
|
+
*/
|
|
54
|
+
readonly presenter?: boolean;
|
|
34
55
|
/**
|
|
35
56
|
* Names this step as a still-capture point for `scenar shoot`.
|
|
36
57
|
*
|
|
@@ -47,4 +68,17 @@ export interface ScenarioStep<T> {
|
|
|
47
68
|
* bundle. Steps without a `shot` are simply walked through.
|
|
48
69
|
*/
|
|
49
70
|
readonly shot?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Marks this step as an engine-synthesized title card. Only
|
|
73
|
+
* `applyTitleCards` constructs card steps — authors configure cards
|
|
74
|
+
* through the scenario-level `titleCards` config, never by hand.
|
|
75
|
+
*
|
|
76
|
+
* The player renders card steps with its built-in card component and
|
|
77
|
+
* never calls the scenario's render function for them, so a card
|
|
78
|
+
* step's `data` is a placeholder that is never read. Card steps
|
|
79
|
+
* announce activation through the player's `onCardStepChange`
|
|
80
|
+
* callback instead of `onStepChange` — the engine cannot fabricate a
|
|
81
|
+
* real `T` for the latter.
|
|
82
|
+
*/
|
|
83
|
+
readonly card?: StepCard;
|
|
50
84
|
}
|
|
@@ -57,4 +57,25 @@ describe("computeStepTimeline", () => {
|
|
|
57
57
|
expect(tl.stepStartTimesMs).toEqual([0]);
|
|
58
58
|
expect(tl.totalDurationMs).toBe(3000);
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it("uses a final card step's duration as the closing dwell", () => {
|
|
62
|
+
const tl = computeStepTimeline(
|
|
63
|
+
[{ delayMs: 0 }, { delayMs: 3000, card: { durationMs: 5000 } }],
|
|
64
|
+
null,
|
|
65
|
+
);
|
|
66
|
+
expect(tl.totalDurationMs).toBe(3000 + 5000);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("falls back to the default dwell for a final card step without a duration", () => {
|
|
70
|
+
const tl = computeStepTimeline([{ delayMs: 0 }, { delayMs: 3000, card: {} }], null);
|
|
71
|
+
expect(tl.totalDurationMs).toBe(3000 + 3000);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ignores a non-final card step's duration for the closing dwell", () => {
|
|
75
|
+
const tl = computeStepTimeline(
|
|
76
|
+
[{ delayMs: 0, card: { durationMs: 9000 } }, { delayMs: 2000 }],
|
|
77
|
+
null,
|
|
78
|
+
);
|
|
79
|
+
expect(tl.totalDurationMs).toBe(2000 + 3000);
|
|
80
|
+
});
|
|
60
81
|
});
|