@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.
Files changed (69) hide show
  1. package/index.d.ts +17 -1
  2. package/index.d.ts.map +1 -1
  3. package/index.js +9 -1
  4. package/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/presenter/derive-presenter-timeline.d.ts +36 -0
  7. package/presenter/derive-presenter-timeline.d.ts.map +1 -0
  8. package/presenter/derive-presenter-timeline.js +31 -0
  9. package/presenter/derive-presenter-timeline.js.map +1 -0
  10. package/presenter/presenter-opacity.d.ts +32 -0
  11. package/presenter/presenter-opacity.d.ts.map +1 -0
  12. package/presenter/presenter-opacity.js +40 -0
  13. package/presenter/presenter-opacity.js.map +1 -0
  14. package/presenter/types.d.ts +45 -0
  15. package/presenter/types.d.ts.map +1 -0
  16. package/presenter/types.js +16 -0
  17. package/presenter/types.js.map +1 -0
  18. package/scenario/apply-title-cards.d.ts +53 -0
  19. package/scenario/apply-title-cards.d.ts.map +1 -0
  20. package/scenario/apply-title-cards.js +113 -0
  21. package/scenario/apply-title-cards.js.map +1 -0
  22. package/scenario/bundle.d.ts +15 -0
  23. package/scenario/bundle.d.ts.map +1 -1
  24. package/scenario/soundtrack.d.ts +46 -0
  25. package/scenario/soundtrack.d.ts.map +1 -0
  26. package/scenario/soundtrack.js +11 -0
  27. package/scenario/soundtrack.js.map +1 -0
  28. package/scenario/title-cards.d.ts +64 -0
  29. package/scenario/title-cards.d.ts.map +1 -0
  30. package/scenario/title-cards.js +20 -0
  31. package/scenario/title-cards.js.map +1 -0
  32. package/scenario/types.d.ts +36 -2
  33. package/scenario/types.d.ts.map +1 -1
  34. package/src/index.ts +32 -1
  35. package/src/presenter/derive-presenter-timeline.test.ts +138 -0
  36. package/src/presenter/derive-presenter-timeline.ts +54 -0
  37. package/src/presenter/presenter-opacity.test.ts +47 -0
  38. package/src/presenter/presenter-opacity.ts +43 -0
  39. package/src/presenter/types.ts +46 -0
  40. package/src/scenario/apply-title-cards.test.ts +268 -0
  41. package/src/scenario/apply-title-cards.ts +141 -0
  42. package/src/scenario/bundle.ts +15 -0
  43. package/src/scenario/soundtrack.ts +47 -0
  44. package/src/scenario/title-cards.ts +67 -0
  45. package/src/scenario/types.ts +36 -2
  46. package/src/timeline/compute-step-timeline.test.ts +21 -0
  47. package/src/timeline/compute-step-timeline.ts +30 -7
  48. package/src/timeline/derive-action-events.test.ts +144 -0
  49. package/src/timeline/derive-action-events.ts +116 -0
  50. package/src/timeline/derive-sfx-timeline.test.ts +114 -0
  51. package/src/timeline/derive-sfx-timeline.ts +79 -0
  52. package/src/timeline/music-envelope.test.ts +141 -0
  53. package/src/timeline/music-envelope.ts +131 -0
  54. package/timeline/compute-step-timeline.d.ts +28 -3
  55. package/timeline/compute-step-timeline.d.ts.map +1 -1
  56. package/timeline/compute-step-timeline.js +11 -3
  57. package/timeline/compute-step-timeline.js.map +1 -1
  58. package/timeline/derive-action-events.d.ts +40 -0
  59. package/timeline/derive-action-events.d.ts.map +1 -0
  60. package/timeline/derive-action-events.js +71 -0
  61. package/timeline/derive-action-events.js.map +1 -0
  62. package/timeline/derive-sfx-timeline.d.ts +36 -0
  63. package/timeline/derive-sfx-timeline.d.ts.map +1 -0
  64. package/timeline/derive-sfx-timeline.js +49 -0
  65. package/timeline/derive-sfx-timeline.js.map +1 -0
  66. package/timeline/music-envelope.d.ts +69 -0
  67. package/timeline/music-envelope.d.ts.map +1 -0
  68. package/timeline/music-envelope.js +95 -0
  69. package/timeline/music-envelope.js.map +1 -0
@@ -0,0 +1,64 @@
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
+ /** One card's content — deliberately small; a card, not a page builder. */
16
+ export interface TitleCard {
17
+ /** Headline text. The one required field. */
18
+ readonly title: string;
19
+ /** Supporting line rendered under the title. */
20
+ readonly subtitle?: string;
21
+ /**
22
+ * Logo image asset reference. Like narration and music srcs, a
23
+ * relative path resolves against the scenario's own location; an
24
+ * absolute URL passes through unchanged. Raster web formats only
25
+ * (png, jpg/jpeg, gif, webp, avif) — the deploy contract excludes
26
+ * svg as active content.
27
+ */
28
+ readonly logoSrc?: string;
29
+ /**
30
+ * Call-to-action text rendered as a distinct pill. Display-only —
31
+ * not a link. Typically used on the outro.
32
+ */
33
+ readonly ctaText?: string;
34
+ /**
35
+ * How long the card stays on screen, in milliseconds.
36
+ * Defaults to {@link TITLE_CARD_DURATION_DEFAULT_MS}.
37
+ */
38
+ readonly durationMs?: number;
39
+ }
40
+ /**
41
+ * The scenario-level card configuration: an intro card, an outro card,
42
+ * or both. Either field may be set independently; neither set is a no-op.
43
+ */
44
+ export interface TitleCards {
45
+ /** Opening card shown before the first authored step. */
46
+ readonly intro?: TitleCard;
47
+ /** Closing card shown after the last authored step. */
48
+ readonly outro?: TitleCard;
49
+ }
50
+ /**
51
+ * The card marker carried by a synthesized card step
52
+ * ({@link ScenarioStep.card}). `kind` records which side of the
53
+ * scenario the card frames so the renderer can style intro and outro
54
+ * distinctly if it chooses.
55
+ */
56
+ export interface StepCard extends TitleCard {
57
+ readonly kind: "intro" | "outro";
58
+ }
59
+ /**
60
+ * Default card visible time. Long enough to read a title and subtitle,
61
+ * short enough to not delay the content.
62
+ */
63
+ export declare const TITLE_CARD_DURATION_DEFAULT_MS = 3000;
64
+ //# sourceMappingURL=title-cards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"title-cards.d.ts","sourceRoot":"","sources":["../../src/scenario/title-cards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,SAAS;IACxB,6CAA6C;IAC7C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,gDAAgD;IAChD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,yDAAyD;IACzD,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAC3B,uDAAuD;IACvD,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;CAC5B;AAED;;;;;GAKG;AACH,MAAM,WAAW,QAAS,SAAQ,SAAS;IACzC,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;CAClC;AAED;;;GAGG;AACH,eAAO,MAAM,8BAA8B,OAAQ,CAAC"}
@@ -0,0 +1,20 @@
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
+ * Default card visible time. Long enough to read a title and subtitle,
17
+ * short enough to not delay the content.
18
+ */
19
+ export const TITLE_CARD_DURATION_DEFAULT_MS = 3_000;
20
+ //# sourceMappingURL=title-cards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"title-cards.js","sourceRoot":"","sources":["../../src/scenario/title-cards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAiDH;;;GAGG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,KAAK,CAAC"}
@@ -5,6 +5,7 @@
5
5
  * playback engine. They are framework-agnostic — no React, no DOM.
6
6
  */
7
7
  import type { StepAction } from "./step-action.js";
8
+ import type { StepCard } from "./title-cards.js";
8
9
  /**
9
10
  * A single step in a scenario timeline.
10
11
  *
@@ -16,8 +17,12 @@ export interface ScenarioStep<T> {
16
17
  /** Data snapshot at this point in the timeline. */
17
18
  readonly data: T;
18
19
  /**
19
- * Narration script for TTS generation. Consumed by the build script
20
- * to produce audio files not rendered at runtime.
20
+ * Narration script for this step. Consumed by `scenar narrate` to
21
+ * produce audio files, and rendered at runtime as the step's caption
22
+ * when the player has captions enabled (a presentation preference —
23
+ * the `captions` player prop, the `?captions=1` embed param, or
24
+ * `scenar render --captions`). Steps without narration play
25
+ * uncaptioned.
21
26
  */
22
27
  readonly narration?: string;
23
28
  /**
@@ -29,6 +34,22 @@ export interface ScenarioStep<T> {
29
34
  * share the same atPercent, they fire in array order.
30
35
  */
31
36
  readonly interactions?: readonly StepAction[];
37
+ /**
38
+ * Marks this step for the presenter track. When true, `scenar presenter`
39
+ * generates an avatar clip lip-synced to this step's narration audio,
40
+ * and both outputs show the clip picture-in-picture while the step is
41
+ * active — the interactive embed and the exported video alike.
42
+ *
43
+ * Requires `narration`: the presenter clip is derived from the step's
44
+ * narration audio by definition, so a step without narration cannot
45
+ * opt in (validated at load time).
46
+ *
47
+ * Playback never calls an AI service. Like narration audio, presenter
48
+ * clips are generated at compile time by the CLI and consumed as fixed
49
+ * assets through a positional manifest; a scenario whose clips have
50
+ * not been generated yet simply plays without the presenter.
51
+ */
52
+ readonly presenter?: boolean;
32
53
  /**
33
54
  * Names this step as a still-capture point for `scenar shoot`.
34
55
  *
@@ -45,5 +66,18 @@ export interface ScenarioStep<T> {
45
66
  * bundle. Steps without a `shot` are simply walked through.
46
67
  */
47
68
  readonly shot?: string;
69
+ /**
70
+ * Marks this step as an engine-synthesized title card. Only
71
+ * `applyTitleCards` constructs card steps — authors configure cards
72
+ * through the scenario-level `titleCards` config, never by hand.
73
+ *
74
+ * The player renders card steps with its built-in card component and
75
+ * never calls the scenario's render function for them, so a card
76
+ * step's `data` is a placeholder that is never read. Card steps
77
+ * announce activation through the player's `onCardStepChange`
78
+ * callback instead of `onStepChange` — the engine cannot fabricate a
79
+ * real `T` for the latter.
80
+ */
81
+ readonly card?: StepCard;
48
82
  }
49
83
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scenario/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACjB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IAC9C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scenario/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACjB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;OAOG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IAC9C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;CAC1B"}
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,
@@ -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
+ }