@scenar/core 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/embed/host-controller.d.ts +7 -0
- package/embed/host-controller.d.ts.map +1 -1
- package/embed/host-controller.js +1 -0
- package/embed/host-controller.js.map +1 -1
- package/embed/protocol.d.ts +35 -3
- package/embed/protocol.d.ts.map +1 -1
- package/embed/protocol.js +37 -4
- package/embed/protocol.js.map +1 -1
- package/index.d.ts +18 -2
- 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/embed/host-controller.test.ts +6 -0
- package/src/embed/host-controller.ts +8 -0
- package/src/embed/protocol.test.ts +53 -0
- package/src/embed/protocol.ts +76 -8
- package/src/index.ts +33 -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
package/src/index.ts
CHANGED
|
@@ -10,9 +10,28 @@ export type {
|
|
|
10
10
|
UseStepInteractionsOptions,
|
|
11
11
|
} from "./scenario/step-action.js";
|
|
12
12
|
|
|
13
|
+
// Soundtrack
|
|
14
|
+
export type { Soundtrack } from "./scenario/soundtrack.js";
|
|
15
|
+
export {
|
|
16
|
+
MUSIC_VOLUME_DEFAULT,
|
|
17
|
+
DUCKING_VOLUME_DEFAULT,
|
|
18
|
+
} from "./scenario/soundtrack.js";
|
|
19
|
+
|
|
20
|
+
// Title cards
|
|
21
|
+
export type { TitleCard, TitleCards, StepCard } from "./scenario/title-cards.js";
|
|
22
|
+
export { TITLE_CARD_DURATION_DEFAULT_MS } from "./scenario/title-cards.js";
|
|
23
|
+
export type { AppliedTitleCards } from "./scenario/apply-title-cards.js";
|
|
24
|
+
export { applyTitleCards } from "./scenario/apply-title-cards.js";
|
|
25
|
+
|
|
13
26
|
// Narration types
|
|
14
27
|
export type { NarrationEntry, NarrationManifest } from "./narration/types.js";
|
|
15
28
|
|
|
29
|
+
// Presenter track
|
|
30
|
+
export type { PresenterEntry, PresenterManifest } from "./presenter/types.js";
|
|
31
|
+
export type { PresenterWindow } from "./presenter/derive-presenter-timeline.js";
|
|
32
|
+
export { derivePresenterTimeline } from "./presenter/derive-presenter-timeline.js";
|
|
33
|
+
export { PRESENTER_FADE_MS, presenterOpacityAt } from "./presenter/presenter-opacity.js";
|
|
34
|
+
|
|
16
35
|
// Viewport types
|
|
17
36
|
export type { ViewportTransform, ViewportCameraMove } from "./viewport/transform.js";
|
|
18
37
|
export {
|
|
@@ -28,9 +47,21 @@ export { computeCursorPosition } from "./cursor/compute-position.js";
|
|
|
28
47
|
|
|
29
48
|
// Timeline
|
|
30
49
|
export type { StepTimeline } from "./timeline/compute-step-timeline.js";
|
|
31
|
-
export { computeStepTimeline } from "./timeline/compute-step-timeline.js";
|
|
50
|
+
export { FINAL_DWELL_MS, computeStepTimeline } from "./timeline/compute-step-timeline.js";
|
|
32
51
|
export { deriveStepFromTime } from "./timeline/derive-step.js";
|
|
33
52
|
export { getStepDurationMs } from "./timeline/step-duration.js";
|
|
53
|
+
export type { ActionEvent, ActionEventKind } from "./timeline/derive-action-events.js";
|
|
54
|
+
export { deriveActionEvents } from "./timeline/derive-action-events.js";
|
|
55
|
+
export type { SfxEvent, SfxSound } from "./timeline/derive-sfx-timeline.js";
|
|
56
|
+
export { deriveSfxTimeline } from "./timeline/derive-sfx-timeline.js";
|
|
57
|
+
export type { DuckingWindow, MusicEnvelope } from "./timeline/music-envelope.js";
|
|
58
|
+
export {
|
|
59
|
+
MUSIC_FADE_IN_MS,
|
|
60
|
+
MUSIC_FADE_OUT_MS,
|
|
61
|
+
DUCKING_RAMP_MS,
|
|
62
|
+
computeMusicEnvelope,
|
|
63
|
+
musicGainAt,
|
|
64
|
+
} from "./timeline/music-envelope.js";
|
|
34
65
|
export type { ScenarioShot } from "./timeline/collect-shots.js";
|
|
35
66
|
export {
|
|
36
67
|
SHOT_NAME_PATTERN,
|
|
@@ -79,6 +110,7 @@ export type {
|
|
|
79
110
|
ScenarEmbedCommand,
|
|
80
111
|
ScenarEmbedEventMessage,
|
|
81
112
|
ScenarEmbedCommandMessage,
|
|
113
|
+
ScenarEmbedViewport,
|
|
82
114
|
} from "./embed/protocol.js";
|
|
83
115
|
|
|
84
116
|
// Embed host controller — framework-free driver for an embedded player
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeStepTimeline } from "../timeline/compute-step-timeline.js";
|
|
3
|
+
import { deriveStepFromTime } from "../timeline/derive-step.js";
|
|
4
|
+
import { applyTitleCards } from "../scenario/apply-title-cards.js";
|
|
5
|
+
import type { ScenarioStep } from "../scenario/types.js";
|
|
6
|
+
import { derivePresenterTimeline } from "./derive-presenter-timeline.js";
|
|
7
|
+
import type { PresenterManifest } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const step = (delayMs: number): ScenarioStep<null> => ({ delayMs, data: null });
|
|
10
|
+
|
|
11
|
+
const entry = (index: number, durationMs: number) => ({
|
|
12
|
+
src: `./step-${index}.mp4`,
|
|
13
|
+
durationMs,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("derivePresenterTimeline", () => {
|
|
17
|
+
it("returns no windows without a manifest", () => {
|
|
18
|
+
const timeline = computeStepTimeline([step(0), step(1000)], undefined);
|
|
19
|
+
expect(derivePresenterTimeline(undefined, timeline)).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns no windows for an all-null manifest", () => {
|
|
23
|
+
const timeline = computeStepTimeline([step(0), step(1000)], undefined);
|
|
24
|
+
const manifest: PresenterManifest = { steps: [null, null] };
|
|
25
|
+
expect(derivePresenterTimeline(manifest, timeline)).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("windows a clip on step 0 at time 0", () => {
|
|
29
|
+
const timeline = computeStepTimeline([step(0), step(1000)], undefined);
|
|
30
|
+
const manifest: PresenterManifest = { steps: [entry(0, 2500), null] };
|
|
31
|
+
|
|
32
|
+
expect(derivePresenterTimeline(manifest, timeline)).toEqual([
|
|
33
|
+
{ stepIndex: 0, startMs: 0, clipDurationMs: 2500 },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("windows a clip on the last step at its start time", () => {
|
|
38
|
+
const steps = [step(0), step(1000), step(800)];
|
|
39
|
+
const manifest: PresenterManifest = { steps: [null, null, entry(2, 1500)] };
|
|
40
|
+
// Step starts derive from narration-compatible durations too; keep
|
|
41
|
+
// this delay-only so startMs is the plain prefix sum: 0, 1000, 1800.
|
|
42
|
+
const timeline = computeStepTimeline(steps, undefined);
|
|
43
|
+
|
|
44
|
+
expect(derivePresenterTimeline(manifest, timeline)).toEqual([
|
|
45
|
+
{ stepIndex: 2, startMs: 1800, clipDurationMs: 1500 },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles a single-step scenario", () => {
|
|
50
|
+
const timeline = computeStepTimeline([step(0)], undefined);
|
|
51
|
+
const manifest: PresenterManifest = { steps: [entry(0, 900)] };
|
|
52
|
+
|
|
53
|
+
expect(derivePresenterTimeline(manifest, timeline)).toEqual([
|
|
54
|
+
{ stepIndex: 0, startMs: 0, clipDurationMs: 900 },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("windows every opted-in step and skips the rest", () => {
|
|
59
|
+
const steps = [step(0), step(1000), step(1000), step(1000)];
|
|
60
|
+
const manifest: PresenterManifest = {
|
|
61
|
+
steps: [entry(0, 500), null, entry(2, 700), null],
|
|
62
|
+
};
|
|
63
|
+
const timeline = computeStepTimeline(steps, undefined);
|
|
64
|
+
|
|
65
|
+
expect(derivePresenterTimeline(manifest, timeline)).toEqual([
|
|
66
|
+
{ stepIndex: 0, startMs: 0, clipDurationMs: 500 },
|
|
67
|
+
{ stepIndex: 2, startMs: 2000, clipDurationMs: 700 },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("ignores manifest entries beyond the timeline's steps (stale manifest degrades)", () => {
|
|
72
|
+
const timeline = computeStepTimeline([step(0)], undefined);
|
|
73
|
+
const manifest: PresenterManifest = {
|
|
74
|
+
steps: [entry(0, 500), entry(1, 700)],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
expect(derivePresenterTimeline(manifest, timeline)).toEqual([
|
|
78
|
+
{ stepIndex: 0, startMs: 0, clipDurationMs: 500 },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("aligns with card-padded manifests from applyTitleCards", () => {
|
|
83
|
+
const authored: ScenarioStep<null>[] = [step(0), step(1500)];
|
|
84
|
+
const narration = {
|
|
85
|
+
steps: [
|
|
86
|
+
{ src: "./step-0.mp3", durationMs: 2000 },
|
|
87
|
+
{ src: "./step-1.mp3", durationMs: 1200 },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
const presenter: PresenterManifest = {
|
|
91
|
+
steps: [entry(0, 2000), null],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const applied = applyTitleCards(authored, narration, {
|
|
95
|
+
intro: { title: "Acme", durationMs: 4000 },
|
|
96
|
+
outro: { title: "Try it" },
|
|
97
|
+
}, presenter);
|
|
98
|
+
const timeline = computeStepTimeline(applied.steps, applied.narrationManifest);
|
|
99
|
+
|
|
100
|
+
// The one opted-in authored step is now index 1 (after the intro),
|
|
101
|
+
// starting when the intro's 4000ms card time elapses.
|
|
102
|
+
expect(derivePresenterTimeline(applied.presenterManifest, timeline)).toEqual([
|
|
103
|
+
{ stepIndex: 1, startMs: 4000, clipDurationMs: 2000 },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("agrees with deriveStepFromTime: a window is active exactly while its step is", () => {
|
|
108
|
+
const steps = [step(0), step(1000), step(3000)];
|
|
109
|
+
const narration = {
|
|
110
|
+
steps: [
|
|
111
|
+
{ src: "./step-0.mp3", durationMs: 2000 },
|
|
112
|
+
null,
|
|
113
|
+
{ src: "./step-2.mp3", durationMs: 1500 },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
// Presenter durations mirror narration durations (the CLI writes
|
|
117
|
+
// them from the narration manifest), so windows fit their steps.
|
|
118
|
+
const presenter: PresenterManifest = {
|
|
119
|
+
steps: [entry(0, 2000), null, entry(2, 1500)],
|
|
120
|
+
};
|
|
121
|
+
const timeline = computeStepTimeline(steps, narration);
|
|
122
|
+
const windows = derivePresenterTimeline(presenter, timeline);
|
|
123
|
+
const lastIndex = steps.length - 1;
|
|
124
|
+
|
|
125
|
+
for (const window of windows) {
|
|
126
|
+
const samples = [
|
|
127
|
+
window.startMs,
|
|
128
|
+
window.startMs + window.clipDurationMs / 2,
|
|
129
|
+
window.startMs + window.clipDurationMs - 1,
|
|
130
|
+
];
|
|
131
|
+
for (const t of samples) {
|
|
132
|
+
expect(deriveStepFromTime(t, timeline.stepStartTimesMs, lastIndex)).toBe(
|
|
133
|
+
window.stepIndex,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { StepTimeline } from "../timeline/compute-step-timeline.js";
|
|
2
|
+
import type { PresenterManifest } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One presenter clip's place on the scenario timeline: when it becomes
|
|
6
|
+
* visible (its step's start) and how long it plays.
|
|
7
|
+
*
|
|
8
|
+
* A window never outlives its step — `scenar presenter` writes clip
|
|
9
|
+
* durations from the narration manifest, and step timing already waits
|
|
10
|
+
* for narration (`max(delayMs, narration duration)`), so the clip fits
|
|
11
|
+
* by construction. When the step outlives the clip (a long `delayMs`),
|
|
12
|
+
* the frame fades out at clip end (see `presenterOpacityAt`).
|
|
13
|
+
*/
|
|
14
|
+
export interface PresenterWindow {
|
|
15
|
+
/** Index into the EXPANDED step list (same domain as `stepTimeline`). */
|
|
16
|
+
readonly stepIndex: number;
|
|
17
|
+
/** Clip start in scenario time — the step's start, in milliseconds. */
|
|
18
|
+
readonly startMs: number;
|
|
19
|
+
/** Clip length in milliseconds. */
|
|
20
|
+
readonly clipDurationMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Derive every presenter clip's timeline window from the (expanded,
|
|
25
|
+
* card-padded) presenter manifest and the step timeline computed over
|
|
26
|
+
* the same expanded steps.
|
|
27
|
+
*
|
|
28
|
+
* This is the one implementation both outputs consume: the browser
|
|
29
|
+
* player uses windows for expected-position math in its drift
|
|
30
|
+
* corrections; the video export converts `startMs` to frames for
|
|
31
|
+
* Sequence placement. Pure function — same inputs, same windows, in
|
|
32
|
+
* both time domains.
|
|
33
|
+
*
|
|
34
|
+
* Manifest entries beyond the timeline's step count are ignored (a
|
|
35
|
+
* stale manifest degrades, exactly as narration does).
|
|
36
|
+
*/
|
|
37
|
+
export function derivePresenterTimeline(
|
|
38
|
+
presenterManifest: PresenterManifest | undefined,
|
|
39
|
+
stepTimeline: StepTimeline,
|
|
40
|
+
): readonly PresenterWindow[] {
|
|
41
|
+
if (!presenterManifest) return [];
|
|
42
|
+
|
|
43
|
+
const windows: PresenterWindow[] = [];
|
|
44
|
+
for (let i = 0; i < stepTimeline.stepStartTimesMs.length; i++) {
|
|
45
|
+
const entry = presenterManifest.steps[i];
|
|
46
|
+
if (!entry) continue;
|
|
47
|
+
windows.push({
|
|
48
|
+
stepIndex: i,
|
|
49
|
+
startMs: stepTimeline.stepStartTimesMs[i]!,
|
|
50
|
+
clipDurationMs: entry.durationMs,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return windows;
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PRESENTER_FADE_MS, presenterOpacityAt } from "./presenter-opacity.js";
|
|
3
|
+
|
|
4
|
+
describe("presenterOpacityAt", () => {
|
|
5
|
+
const CLIP_MS = 5000;
|
|
6
|
+
|
|
7
|
+
it.each([
|
|
8
|
+
["before the clip", -1, 0],
|
|
9
|
+
["at clip start (first frame of the fade-in)", 0, 0],
|
|
10
|
+
["mid fade-in", PRESENTER_FADE_MS / 2, 0.5],
|
|
11
|
+
["at fade-in end", PRESENTER_FADE_MS, 1],
|
|
12
|
+
["mid-clip", CLIP_MS / 2, 1],
|
|
13
|
+
["at fade-out start", CLIP_MS - PRESENTER_FADE_MS, 1],
|
|
14
|
+
["mid fade-out", CLIP_MS - PRESENTER_FADE_MS / 2, 0.5],
|
|
15
|
+
["at clip end", CLIP_MS, 0],
|
|
16
|
+
["after the clip (step outlives it)", CLIP_MS + 1000, 0],
|
|
17
|
+
])("is %s → %d", (_label, intraStepMs, expected) => {
|
|
18
|
+
expect(presenterOpacityAt(intraStepMs, CLIP_MS)).toBeCloseTo(expected, 5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps a clip shorter than two fades continuous, peaking at its midpoint", () => {
|
|
22
|
+
const shortClip = PRESENTER_FADE_MS; // 200ms: ramps intersect at 100ms
|
|
23
|
+
expect(presenterOpacityAt(0, shortClip)).toBe(0);
|
|
24
|
+
expect(presenterOpacityAt(shortClip / 2, shortClip)).toBeCloseTo(0.5, 5);
|
|
25
|
+
expect(presenterOpacityAt(shortClip, shortClip)).toBe(0);
|
|
26
|
+
// Monotonic up to the midpoint, down after it.
|
|
27
|
+
expect(presenterOpacityAt(40, shortClip)).toBeLessThan(
|
|
28
|
+
presenterOpacityAt(80, shortClip),
|
|
29
|
+
);
|
|
30
|
+
expect(presenterOpacityAt(160, shortClip)).toBeLessThan(
|
|
31
|
+
presenterOpacityAt(120, shortClip),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never exceeds 1 or drops below 0 across the whole clip", () => {
|
|
36
|
+
for (let t = -100; t <= CLIP_MS + 100; t += 25) {
|
|
37
|
+
const opacity = presenterOpacityAt(t, CLIP_MS);
|
|
38
|
+
expect(opacity).toBeGreaterThanOrEqual(0);
|
|
39
|
+
expect(opacity).toBeLessThanOrEqual(1);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is 0 for a zero-duration clip", () => {
|
|
44
|
+
expect(presenterOpacityAt(0, 0)).toBe(0);
|
|
45
|
+
expect(presenterOpacityAt(100, 0)).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presenter frame's fade treatment, as a pure function of time —
|
|
3
|
+
* so the browser player and the video export render the identical
|
|
4
|
+
* fade from the same math (one implementation, both time domains).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fade length at each end of a presenter clip, in milliseconds.
|
|
9
|
+
*
|
|
10
|
+
* The fade-in doubles as the swap treatment: it absorbs the measured
|
|
11
|
+
* src-swap pop-in (≤133 ms worst case across browsers), so no
|
|
12
|
+
* double-buffering machinery is needed. The fade-out is the exit
|
|
13
|
+
* grammar — the presenter finishes and steps aside, rather than
|
|
14
|
+
* freezing mid-gesture when the step outlives the clip.
|
|
15
|
+
*/
|
|
16
|
+
export const PRESENTER_FADE_MS = 200;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The presenter frame's opacity at `intraStepMs` milliseconds into its
|
|
20
|
+
* step, for a clip of `clipDurationMs`.
|
|
21
|
+
*
|
|
22
|
+
* Ramps 0→1 over the first {@link PRESENTER_FADE_MS}, holds 1, ramps
|
|
23
|
+
* 1→0 over the last {@link PRESENTER_FADE_MS}, and is 0 outside the
|
|
24
|
+
* clip (before its start, and for the remainder of a step that
|
|
25
|
+
* outlives it). Clips shorter than two fades never reach full
|
|
26
|
+
* opacity — the ramps intersect at the midpoint, keeping the curve
|
|
27
|
+
* continuous.
|
|
28
|
+
*
|
|
29
|
+
* Reduced-motion is a consumer concern: the browser player hides the
|
|
30
|
+
* presenter entirely under `prefers-reduced-motion`, and the export
|
|
31
|
+
* has no such media query — neither needs a flag here.
|
|
32
|
+
*/
|
|
33
|
+
export function presenterOpacityAt(
|
|
34
|
+
intraStepMs: number,
|
|
35
|
+
clipDurationMs: number,
|
|
36
|
+
): number {
|
|
37
|
+
if (clipDurationMs <= 0) return 0;
|
|
38
|
+
if (intraStepMs <= 0 || intraStepMs >= clipDurationMs) return 0;
|
|
39
|
+
|
|
40
|
+
const fadeIn = intraStepMs / PRESENTER_FADE_MS;
|
|
41
|
+
const fadeOut = (clipDurationMs - intraStepMs) / PRESENTER_FADE_MS;
|
|
42
|
+
return Math.min(fadeIn, fadeOut, 1);
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime presenter clip data produced by `scenar presenter`.
|
|
3
|
+
*
|
|
4
|
+
* The presenter track is structurally a second narration pipeline:
|
|
5
|
+
* generation happens at compile time (the CLI calls the avatar
|
|
6
|
+
* provider once and stores the result), and playback consumes fixed
|
|
7
|
+
* assets through a positional manifest. Playback never calls an AI
|
|
8
|
+
* service — a scenario whose clips have not been generated yet simply
|
|
9
|
+
* plays without the presenter.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately its own type, not a generic "clip manifest" shared with
|
|
12
|
+
* narration: the two are structurally identical today, but their doc
|
|
13
|
+
* contracts differ, and that difference IS the domain knowledge.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A single presenter clip for one scenario step. */
|
|
17
|
+
export interface PresenterEntry {
|
|
18
|
+
/**
|
|
19
|
+
* URL of the clip, relative to the manifest's own location
|
|
20
|
+
* (e.g. "./step-2.mp4") — the same convention narration uses.
|
|
21
|
+
*/
|
|
22
|
+
readonly src: string;
|
|
23
|
+
/**
|
|
24
|
+
* Duration of the clip in milliseconds. Written from the narration
|
|
25
|
+
* manifest's duration for the same step (the clip is lip-synced to
|
|
26
|
+
* that audio and matches it exactly), so presenter timing and
|
|
27
|
+
* narration timing can never disagree.
|
|
28
|
+
*/
|
|
29
|
+
readonly durationMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-scenario manifest mapping step indices to presenter clips.
|
|
34
|
+
*
|
|
35
|
+
* Array position corresponds to the AUTHORED step index — like the
|
|
36
|
+
* narration manifest, it never knows about synthesized title-card
|
|
37
|
+
* steps. Bundle assembly (`applyTitleCards`) pads `null` entries at
|
|
38
|
+
* injected card positions so the expanded manifest lines up with the
|
|
39
|
+
* expanded steps; cards never show the presenter.
|
|
40
|
+
*
|
|
41
|
+
* Steps without a presenter (not opted in, or generation failed and
|
|
42
|
+
* playback degrades per-step) use `null`.
|
|
43
|
+
*/
|
|
44
|
+
export interface PresenterManifest {
|
|
45
|
+
readonly steps: readonly (PresenterEntry | null)[];
|
|
46
|
+
}
|
|
@@ -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
|
+
});
|