@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.
Files changed (81) hide show
  1. package/embed/host-controller.d.ts +7 -0
  2. package/embed/host-controller.d.ts.map +1 -1
  3. package/embed/host-controller.js +1 -0
  4. package/embed/host-controller.js.map +1 -1
  5. package/embed/protocol.d.ts +35 -3
  6. package/embed/protocol.d.ts.map +1 -1
  7. package/embed/protocol.js +37 -4
  8. package/embed/protocol.js.map +1 -1
  9. package/index.d.ts +18 -2
  10. package/index.d.ts.map +1 -1
  11. package/index.js +9 -1
  12. package/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/presenter/derive-presenter-timeline.d.ts +36 -0
  15. package/presenter/derive-presenter-timeline.d.ts.map +1 -0
  16. package/presenter/derive-presenter-timeline.js +31 -0
  17. package/presenter/derive-presenter-timeline.js.map +1 -0
  18. package/presenter/presenter-opacity.d.ts +32 -0
  19. package/presenter/presenter-opacity.d.ts.map +1 -0
  20. package/presenter/presenter-opacity.js +40 -0
  21. package/presenter/presenter-opacity.js.map +1 -0
  22. package/presenter/types.d.ts +45 -0
  23. package/presenter/types.d.ts.map +1 -0
  24. package/presenter/types.js +16 -0
  25. package/presenter/types.js.map +1 -0
  26. package/scenario/apply-title-cards.d.ts +53 -0
  27. package/scenario/apply-title-cards.d.ts.map +1 -0
  28. package/scenario/apply-title-cards.js +113 -0
  29. package/scenario/apply-title-cards.js.map +1 -0
  30. package/scenario/bundle.d.ts +15 -0
  31. package/scenario/bundle.d.ts.map +1 -1
  32. package/scenario/soundtrack.d.ts +46 -0
  33. package/scenario/soundtrack.d.ts.map +1 -0
  34. package/scenario/soundtrack.js +11 -0
  35. package/scenario/soundtrack.js.map +1 -0
  36. package/scenario/title-cards.d.ts +64 -0
  37. package/scenario/title-cards.d.ts.map +1 -0
  38. package/scenario/title-cards.js +20 -0
  39. package/scenario/title-cards.js.map +1 -0
  40. package/scenario/types.d.ts +36 -2
  41. package/scenario/types.d.ts.map +1 -1
  42. package/src/embed/host-controller.test.ts +6 -0
  43. package/src/embed/host-controller.ts +8 -0
  44. package/src/embed/protocol.test.ts +53 -0
  45. package/src/embed/protocol.ts +76 -8
  46. package/src/index.ts +33 -1
  47. package/src/presenter/derive-presenter-timeline.test.ts +138 -0
  48. package/src/presenter/derive-presenter-timeline.ts +54 -0
  49. package/src/presenter/presenter-opacity.test.ts +47 -0
  50. package/src/presenter/presenter-opacity.ts +43 -0
  51. package/src/presenter/types.ts +46 -0
  52. package/src/scenario/apply-title-cards.test.ts +268 -0
  53. package/src/scenario/apply-title-cards.ts +141 -0
  54. package/src/scenario/bundle.ts +15 -0
  55. package/src/scenario/soundtrack.ts +47 -0
  56. package/src/scenario/title-cards.ts +67 -0
  57. package/src/scenario/types.ts +36 -2
  58. package/src/timeline/compute-step-timeline.test.ts +21 -0
  59. package/src/timeline/compute-step-timeline.ts +30 -7
  60. package/src/timeline/derive-action-events.test.ts +144 -0
  61. package/src/timeline/derive-action-events.ts +116 -0
  62. package/src/timeline/derive-sfx-timeline.test.ts +114 -0
  63. package/src/timeline/derive-sfx-timeline.ts +79 -0
  64. package/src/timeline/music-envelope.test.ts +141 -0
  65. package/src/timeline/music-envelope.ts +131 -0
  66. package/timeline/compute-step-timeline.d.ts +28 -3
  67. package/timeline/compute-step-timeline.d.ts.map +1 -1
  68. package/timeline/compute-step-timeline.js +11 -3
  69. package/timeline/compute-step-timeline.js.map +1 -1
  70. package/timeline/derive-action-events.d.ts +40 -0
  71. package/timeline/derive-action-events.d.ts.map +1 -0
  72. package/timeline/derive-action-events.js +71 -0
  73. package/timeline/derive-action-events.js.map +1 -0
  74. package/timeline/derive-sfx-timeline.d.ts +36 -0
  75. package/timeline/derive-sfx-timeline.d.ts.map +1 -0
  76. package/timeline/derive-sfx-timeline.js +49 -0
  77. package/timeline/derive-sfx-timeline.js.map +1 -0
  78. package/timeline/music-envelope.d.ts +69 -0
  79. package/timeline/music-envelope.d.ts.map +1 -0
  80. package/timeline/music-envelope.js +95 -0
  81. package/timeline/music-envelope.js.map +1 -0
@@ -0,0 +1,113 @@
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
+ import { FINAL_DWELL_MS } from "../timeline/compute-step-timeline.js";
22
+ import { TITLE_CARD_DURATION_DEFAULT_MS, } from "./title-cards.js";
23
+ /**
24
+ * A card step's `data` placeholder. Card steps are rendered by the
25
+ * player's built-in card component — the integrator's render callback
26
+ * (the only reader of `data`) is never invoked for them, so no real
27
+ * `T` value is ever needed. See the `ScenarioStep.card` doc contract.
28
+ */
29
+ function cardStepData() {
30
+ return undefined;
31
+ }
32
+ /**
33
+ * Expand `steps` (and the index-parallel narration and presenter
34
+ * manifests, when they exist) with the configured intro/outro cards.
35
+ *
36
+ * - **Intro**: prepended at index 0 with `delayMs: 0`; the card's
37
+ * visible time is encoded as the following step's transition delay
38
+ * (`max(authored delayMs, card duration)`), which is exactly how
39
+ * `computeStepTimeline` reads step 0's duration.
40
+ * - **Outro**: appended with `delayMs: FINAL_DWELL_MS`, so the last
41
+ * authored step keeps precisely the closing dwell it has today (or
42
+ * its narration, whichever is longer) before the card appears. The
43
+ * card's own visible time is its `durationMs` — `computeStepTimeline`
44
+ * reads a final card step's duration as the closing dwell. The outro
45
+ * carries two housekeeping interactions at step entry (`clear_cursor`
46
+ * and a viewport reset) so a scenario ending with a visible cursor or
47
+ * an active zoom never leaks that state onto the card. Neither action
48
+ * maps to a sound effect.
49
+ *
50
+ * Pure and non-mutating. When `titleCards` configures no card, the
51
+ * inputs are returned as-is (byte-identical no-op).
52
+ */
53
+ export function applyTitleCards(steps, narrationManifest, titleCards, presenterManifest) {
54
+ const intro = titleCards?.intro;
55
+ const outro = titleCards?.outro;
56
+ if (!intro && !outro) {
57
+ return { steps, narrationManifest, presenterManifest };
58
+ }
59
+ const expandedSteps = [...steps];
60
+ let manifestEntries = narrationManifest ? [...narrationManifest.steps] : undefined;
61
+ let presenterEntries = presenterManifest ? [...presenterManifest.steps] : undefined;
62
+ if (intro && expandedSteps.length > 0) {
63
+ const introDurationMs = intro.durationMs ?? TITLE_CARD_DURATION_DEFAULT_MS;
64
+ const firstAuthored = expandedSteps[0];
65
+ expandedSteps[0] = {
66
+ ...firstAuthored,
67
+ delayMs: Math.max(firstAuthored.delayMs, introDurationMs),
68
+ };
69
+ expandedSteps.unshift({
70
+ delayMs: 0,
71
+ data: cardStepData(),
72
+ card: { kind: "intro", ...pickCardContent(intro) },
73
+ });
74
+ manifestEntries?.unshift(null);
75
+ presenterEntries?.unshift(null);
76
+ }
77
+ if (outro) {
78
+ expandedSteps.push({
79
+ delayMs: FINAL_DWELL_MS,
80
+ data: cardStepData(),
81
+ card: { kind: "outro", ...pickCardContent(outro) },
82
+ interactions: [
83
+ { atPercent: 0, type: "clear_cursor" },
84
+ { atPercent: 0, type: "viewport_transition", viewportReset: true },
85
+ ],
86
+ });
87
+ manifestEntries?.push(null);
88
+ presenterEntries?.push(null);
89
+ }
90
+ return {
91
+ steps: expandedSteps,
92
+ narrationManifest: manifestEntries ? { steps: manifestEntries } : undefined,
93
+ presenterManifest: presenterEntries ? { steps: presenterEntries } : undefined,
94
+ };
95
+ }
96
+ /**
97
+ * Copy only the known card fields, so a config object carrying strays
98
+ * (e.g. a loosely-typed steps.ts export) never smuggles them into the
99
+ * step list.
100
+ */
101
+ function pickCardContent(card) {
102
+ const content = { title: card.title };
103
+ if (card.subtitle !== undefined)
104
+ content.subtitle = card.subtitle;
105
+ if (card.logoSrc !== undefined)
106
+ content.logoSrc = card.logoSrc;
107
+ if (card.ctaText !== undefined)
108
+ content.ctaText = card.ctaText;
109
+ if (card.durationMs !== undefined)
110
+ content.durationMs = card.durationMs;
111
+ return content;
112
+ }
113
+ //# sourceMappingURL=apply-title-cards.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply-title-cards.js","sourceRoot":"","sources":["../../src/scenario/apply-title-cards.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,8 @@
7
7
  * scenario into a single typed value.
8
8
  */
9
9
  import type { NarrationManifest } from "../narration/types.js";
10
+ import type { PresenterManifest } from "../presenter/types.js";
11
+ import type { Soundtrack } from "./soundtrack.js";
10
12
  import type { ScenarioStep } from "./types.js";
11
13
  /**
12
14
  * Everything the engine needs to play one scenario.
@@ -24,5 +26,18 @@ export interface ScenarioBundle<T> {
24
26
  * narration (timing is purely delay-based).
25
27
  */
26
28
  readonly narrationManifest?: NarrationManifest;
29
+ /**
30
+ * Audio treatment: background music with narration ducking and
31
+ * interaction sound effects. When `undefined`, the scenario plays
32
+ * silent apart from narration.
33
+ */
34
+ readonly soundtrack?: Soundtrack;
35
+ /**
36
+ * Pre-built presenter manifest mapping step indices to avatar clip
37
+ * URLs and durations (generated by `scenar presenter`). When
38
+ * `undefined`, the scenario plays without the presenter — zero
39
+ * presenter DOM, zero fetched bytes.
40
+ */
41
+ readonly presenterManifest?: PresenterManifest;
27
42
  }
28
43
  //# sourceMappingURL=bundle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../../src/scenario/bundle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;GAIG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC;IAClC;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CAChD"}
1
+ {"version":3,"file":"bundle.d.ts","sourceRoot":"","sources":["../../src/scenario/bundle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;GAIG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC;IAClC;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CAChD"}
@@ -0,0 +1,46 @@
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
+ * Default base music level. Clearly audible under silence without
38
+ * competing with interface sound effects.
39
+ */
40
+ export declare const MUSIC_VOLUME_DEFAULT = 0.25;
41
+ /**
42
+ * Default ducked music level while narration plays. Present but firmly
43
+ * under the voice.
44
+ */
45
+ export declare const DUCKING_VOLUME_DEFAULT = 0.08;
46
+ //# sourceMappingURL=soundtrack.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"soundtrack.d.ts","sourceRoot":"","sources":["../../src/scenario/soundtrack.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;GAGG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,sBAAsB,OAAO,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Default base music level. Clearly audible under silence without
3
+ * competing with interface sound effects.
4
+ */
5
+ export const MUSIC_VOLUME_DEFAULT = 0.25;
6
+ /**
7
+ * Default ducked music level while narration plays. Present but firmly
8
+ * under the voice.
9
+ */
10
+ export const DUCKING_VOLUME_DEFAULT = 0.08;
11
+ //# sourceMappingURL=soundtrack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"soundtrack.js","sourceRoot":"","sources":["../../src/scenario/soundtrack.ts"],"names":[],"mappings":"AAoCA;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC;AAEzC;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAC"}
@@ -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"}
@@ -62,6 +62,12 @@ describe("createEmbedHostController — commands", () => {
62
62
  EMBED_ORIGIN,
63
63
  );
64
64
 
65
+ controller.setHostScale(0.7);
66
+ expect(post).toHaveBeenLastCalledWith(
67
+ expect.objectContaining({ type: "setHostScale", scale: 0.7 }),
68
+ EMBED_ORIGIN,
69
+ );
70
+
65
71
  controller.destroy();
66
72
  });
67
73
  });
@@ -32,6 +32,13 @@ export interface ScenarEmbedHostController {
32
32
  seek(timeMs: number): void;
33
33
  setMuted(muted: boolean): void;
34
34
  setVolume(volume: number): void;
35
+ /**
36
+ * Report the scale factor the host is rendering the iframe at (1 = native),
37
+ * so the player's chrome layer can counter-scale its transport controls
38
+ * back to native pixel size. Sent by hosts running the iframe-as-screen
39
+ * mode; players without the capability ignore it.
40
+ */
41
+ setHostScale(scale: number): void;
35
42
  prefetch(): void;
36
43
  /**
37
44
  * Tell the embed to stop (best effort) and detach the host message listener.
@@ -86,6 +93,7 @@ export function createEmbedHostController(
86
93
  seek: (timeMs) => send({ type: "seek", timeMs }),
87
94
  setMuted: (muted) => send({ type: "setMuted", muted }),
88
95
  setVolume: (volume) => send({ type: "setVolume", volume }),
96
+ setHostScale: (scale) => send({ type: "setHostScale", scale }),
89
97
  prefetch: () => send({ type: "prefetch" }),
90
98
  destroy: () => {
91
99
  send({ type: "destroy" });
@@ -55,6 +55,21 @@ describe("parseEmbedCommand", () => {
55
55
  });
56
56
  });
57
57
 
58
+ it("accepts setHostScale with a positive finite scale", () => {
59
+ expect(parseEmbedCommand(wrap({ type: "setHostScale", scale: 0.7 }))).toEqual({
60
+ type: "setHostScale",
61
+ scale: 0.7,
62
+ });
63
+ });
64
+
65
+ it("rejects setHostScale with a missing, non-finite, or non-positive scale", () => {
66
+ expect(parseEmbedCommand(wrap({ type: "setHostScale" }))).toBeNull();
67
+ expect(parseEmbedCommand(wrap({ type: "setHostScale", scale: "big" }))).toBeNull();
68
+ expect(parseEmbedCommand(wrap({ type: "setHostScale", scale: Number.NaN }))).toBeNull();
69
+ expect(parseEmbedCommand(wrap({ type: "setHostScale", scale: 0 }))).toBeNull();
70
+ expect(parseEmbedCommand(wrap({ type: "setHostScale", scale: -1 }))).toBeNull();
71
+ });
72
+
58
73
  it("rejects a foreign source", () => {
59
74
  expect(parseEmbedCommand({ source: "other-widget", v: 1, type: "play" })).toBeNull();
60
75
  });
@@ -103,6 +118,44 @@ describe("parseEmbedEvent", () => {
103
118
  expect(parseEmbedEvent(wrap({ type: "ready", totalSteps: 3 }))).toBeNull();
104
119
  });
105
120
 
121
+ it("accepts a ready payload carrying a well-formed canonical viewport", () => {
122
+ expect(
123
+ parseEmbedEvent(
124
+ wrap({
125
+ type: "ready",
126
+ totalSteps: 3,
127
+ hasNarration: false,
128
+ viewport: { widthPx: 1440, heightPx: 900 },
129
+ }),
130
+ ),
131
+ ).toEqual({
132
+ type: "ready",
133
+ totalSteps: 3,
134
+ hasNarration: false,
135
+ viewport: { widthPx: 1440, heightPx: 900 },
136
+ });
137
+ });
138
+
139
+ it("still accepts a ready payload without a viewport (pre-viewport bundles)", () => {
140
+ const parsed = parseEmbedEvent(wrap({ type: "ready", totalSteps: 3, hasNarration: false }));
141
+ expect(parsed).toEqual({ type: "ready", totalSteps: 3, hasNarration: false });
142
+ // The field must be genuinely absent, not present-and-undefined — hosts
143
+ // branch on `event.viewport` to decide iframe-as-screen adoption.
144
+ expect(parsed && "viewport" in parsed).toBe(false);
145
+ });
146
+
147
+ it("rejects a ready payload whose viewport is malformed", () => {
148
+ const base = { type: "ready", totalSteps: 3, hasNarration: false };
149
+ expect(parseEmbedEvent(wrap({ ...base, viewport: "1440x900" }))).toBeNull();
150
+ expect(parseEmbedEvent(wrap({ ...base, viewport: { widthPx: 1440 } }))).toBeNull();
151
+ expect(
152
+ parseEmbedEvent(wrap({ ...base, viewport: { widthPx: 0, heightPx: 900 } })),
153
+ ).toBeNull();
154
+ expect(
155
+ parseEmbedEvent(wrap({ ...base, viewport: { widthPx: "1440", heightPx: 900 } })),
156
+ ).toBeNull();
157
+ });
158
+
106
159
  it("rejects foreign source and unknown event types", () => {
107
160
  expect(parseEmbedEvent({ source: "x", v: 1, type: "started" })).toBeNull();
108
161
  expect(parseEmbedEvent(wrap({ type: "imploded" }))).toBeNull();
@@ -21,11 +21,31 @@ export const SCENAR_EMBED_SOURCE = "scenar-embed";
21
21
  /** Protocol version. Bump only on a breaking change to the message shapes. */
22
22
  export const SCENAR_EMBED_PROTOCOL_VERSION = 1;
23
23
 
24
+ /**
25
+ * The canonical viewport a packed scenario was authored and bundled at, in CSS
26
+ * pixels — the same numbers `scenar pack` bakes into the bundle and records in
27
+ * `scenario.json`.
28
+ *
29
+ * Carried on `ready` so a host can lay the iframe out at this exact size and
30
+ * scale it as one unit (the iframe-as-screen mode in `@scenar/embed`): the
31
+ * embedded document's media queries then resolve against the canonical width,
32
+ * so responsive components render the same variant they would in a real
33
+ * browser window of that size — instead of the narrow variant the host
34
+ * column's width would select.
35
+ */
36
+ export interface ScenarEmbedViewport {
37
+ readonly widthPx: number;
38
+ readonly heightPx: number;
39
+ }
40
+
24
41
  /**
25
42
  * Events the embedded player emits to the host (embed -> host).
26
43
  *
27
44
  * - `ready` — the player has mounted; carries scenario shape the host can use
28
- * to size or label the frame.
45
+ * to size or label the frame. `viewport` (when the bundle knows it) is the
46
+ * canonical size the scenario was packed at — see {@link ScenarEmbedViewport}.
47
+ * Optional for compatibility: bundles packed before the field existed omit
48
+ * it, and hosts must treat its absence as "fit content inside the iframe".
29
49
  * - `resize` — the rendered content height changed (dynamic-height hosts).
30
50
  * - `started` / `paused` / `completed` — transport state transitions.
31
51
  * - `stepchange` — the active step changed.
@@ -35,7 +55,12 @@ export const SCENAR_EMBED_PROTOCOL_VERSION = 1;
35
55
  * - `error` — an unrecoverable runtime error, with a human-readable message.
36
56
  */
37
57
  export type ScenarEmbedEvent =
38
- | { readonly type: "ready"; readonly totalSteps: number; readonly hasNarration: boolean }
58
+ | {
59
+ readonly type: "ready";
60
+ readonly totalSteps: number;
61
+ readonly hasNarration: boolean;
62
+ readonly viewport?: ScenarEmbedViewport;
63
+ }
39
64
  | { readonly type: "resize"; readonly widthPx: number; readonly heightPx: number }
40
65
  | { readonly type: "started" }
41
66
  | { readonly type: "paused" }
@@ -53,8 +78,17 @@ export type ScenarEmbedEvent =
53
78
  /**
54
79
  * Commands the host may send to the embedded player (host -> embed).
55
80
  *
56
- * State-setting commands (`setMuted`, `setVolume`) are idempotent by design so
57
- * a host never has to track the player's internal state to stay in sync.
81
+ * State-setting commands (`setMuted`, `setVolume`, `setHostScale`) are
82
+ * idempotent by design so a host never has to track the player's internal
83
+ * state to stay in sync.
84
+ *
85
+ * `setHostScale` accompanies the iframe-as-screen mode: when the host lays
86
+ * the iframe out at the canonical viewport and scales it as one unit, every
87
+ * pixel inside — including the player's transport controls — shrinks by that
88
+ * factor. The player's chrome layer counter-scales by `1 / scale` so controls
89
+ * keep rendering at native pixel size (the ViewportChrome contract), which
90
+ * only the host can enable because a cross-origin document cannot observe the
91
+ * transform applied to its own frame.
58
92
  */
59
93
  export type ScenarEmbedCommand =
60
94
  | { readonly type: "play" }
@@ -62,6 +96,7 @@ export type ScenarEmbedCommand =
62
96
  | { readonly type: "seek"; readonly timeMs: number }
63
97
  | { readonly type: "setMuted"; readonly muted: boolean }
64
98
  | { readonly type: "setVolume"; readonly volume: number }
99
+ | { readonly type: "setHostScale"; readonly scale: number }
65
100
  | { readonly type: "prefetch" }
66
101
  | { readonly type: "destroy" };
67
102
 
@@ -122,11 +157,35 @@ export function parseEmbedCommand(data: unknown): ScenarEmbedCommand | null {
122
157
  return typeof data["muted"] === "boolean" ? { type, muted: data["muted"] } : null;
123
158
  case "setVolume":
124
159
  return isFiniteNumber(data["volume"]) ? { type, volume: data["volume"] } : null;
160
+ case "setHostScale":
161
+ return isFiniteNumber(data["scale"]) && data["scale"] > 0
162
+ ? { type, scale: data["scale"] }
163
+ : null;
125
164
  default:
126
165
  return null;
127
166
  }
128
167
  }
129
168
 
169
+ /**
170
+ * Parse the optional `viewport` field of a `ready` payload. Absent -> the
171
+ * event simply has no viewport (a pre-viewport bundle). Present but malformed
172
+ * -> the whole event is rejected, exactly like a mistyped required field: a
173
+ * half-valid message is a protocol violation, not a degraded mode.
174
+ */
175
+ function parseReadyViewport(
176
+ value: unknown,
177
+ ): { viewport?: ScenarEmbedViewport } | null {
178
+ if (value === undefined) return {};
179
+ if (typeof value !== "object" || value === null) return null;
180
+ const record = value as Record<string, unknown>;
181
+ return isFiniteNumber(record["widthPx"]) &&
182
+ record["widthPx"] > 0 &&
183
+ isFiniteNumber(record["heightPx"]) &&
184
+ record["heightPx"] > 0
185
+ ? { viewport: { widthPx: record["widthPx"], heightPx: record["heightPx"] } }
186
+ : null;
187
+ }
188
+
130
189
  /**
131
190
  * Parse an inbound `MessageEvent.data` into a typed event, or return `null`.
132
191
  *
@@ -142,10 +201,19 @@ export function parseEmbedEvent(data: unknown): ScenarEmbedEvent | null {
142
201
  case "completed":
143
202
  case "audioBlocked":
144
203
  return { type };
145
- case "ready":
146
- return isFiniteNumber(data["totalSteps"]) && typeof data["hasNarration"] === "boolean"
147
- ? { type, totalSteps: data["totalSteps"], hasNarration: data["hasNarration"] }
148
- : null;
204
+ case "ready": {
205
+ if (!isFiniteNumber(data["totalSteps"]) || typeof data["hasNarration"] !== "boolean") {
206
+ return null;
207
+ }
208
+ const parsedViewport = parseReadyViewport(data["viewport"]);
209
+ if (parsedViewport === null) return null;
210
+ return {
211
+ type,
212
+ totalSteps: data["totalSteps"],
213
+ hasNarration: data["hasNarration"],
214
+ ...parsedViewport,
215
+ };
216
+ }
149
217
  case "resize":
150
218
  return isFiniteNumber(data["widthPx"]) && isFiniteNumber(data["heightPx"])
151
219
  ? { type, widthPx: data["widthPx"], heightPx: data["heightPx"] }