@vanillaskyai/video 0.10.23 → 0.11.1

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 (127) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/PUBLIC-API.md +47 -438
  3. package/README.md +53 -97
  4. package/dist/builtin-metadata-OT6V7TB4.js +8 -0
  5. package/dist/{chapter-title-2JVDU62E.js → chapter-title-2RCXX7SN.js} +1 -2
  6. package/dist/{chunk-5BSJLT6H.js → chunk-2WIETEL7.js} +16 -29
  7. package/dist/{chunk-3O7OMMMF.js → chunk-35K6IKB2.js} +3 -3
  8. package/dist/{chunk-PDQFQIQW.js → chunk-3EQ6PVWL.js} +1 -1
  9. package/dist/chunk-4G4JBMCM.js +37 -0
  10. package/dist/{chunk-7AA2JWHZ.js → chunk-5DOQTIMD.js} +6 -6
  11. package/dist/chunk-666HGTVZ.js +41 -0
  12. package/dist/{chunk-7M56IUUX.js → chunk-6Z3ID54H.js} +0 -18
  13. package/dist/chunk-AUN4S3YW.js +50 -0
  14. package/dist/chunk-HIQM4J3U.js +30 -0
  15. package/dist/chunk-ISWQ6T5X.js +36 -0
  16. package/dist/{chunk-224QNWRA.js → chunk-K5J7ESRO.js} +1 -10
  17. package/dist/chunk-NM4CXXZY.js +25 -0
  18. package/dist/{chunk-44WND2VP.js → chunk-RFXHLLYU.js} +34 -62
  19. package/dist/{chunk-RXTN2CW6.js → chunk-Z3DLSLAJ.js} +1 -1
  20. package/dist/cinema-media-YZ2UANJG.js +365 -0
  21. package/dist/cli.js +154 -1748
  22. package/dist/{compose-video-BH5K6XWT.js → compose-video-RSLI4CVP.js} +4 -4
  23. package/dist/{events-B4YCc4vc.d.ts → events-BZAfl0Dm.d.ts} +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +4 -6
  26. package/dist/preload-media-23JXUFCF.js +77 -0
  27. package/dist/react.d.ts +39 -15
  28. package/dist/react.js +1355 -740
  29. package/dist/scene-validation-SGLLLFY5.js +9 -0
  30. package/dist/server.d.ts +67 -117
  31. package/dist/server.js +296 -626
  32. package/dist/test.d.ts +2 -2
  33. package/dist/test.js +21 -21
  34. package/dist/{text-stream-LD364KBI.js → text-stream-SOVLYR2L.js} +2 -2
  35. package/dist/{types-BqB8zC9u.d.ts → types-CG-kPI81.d.ts} +3 -1
  36. package/dist/{types-DdZw4GRQ.d.ts → types-ht-Zw3Wv.d.ts} +1 -1
  37. package/docs/agent-integration.md +19 -64
  38. package/docs/architecture.md +80 -106
  39. package/docs/customization.md +49 -66
  40. package/docs/development.md +18 -17
  41. package/docs/errors.md +4 -4
  42. package/docs/getting-started.md +74 -93
  43. package/docs/media-and-audio.md +9 -7
  44. package/docs/performance.md +11 -6
  45. package/docs/persistence.md +3 -7
  46. package/docs/production.md +4 -15
  47. package/docs/prompt-and-input.md +1 -2
  48. package/docs/provider-integration.md +156 -181
  49. package/docs/reference/protocol.md +5 -14
  50. package/docs/reference/provider-adapters.md +21 -13
  51. package/docs/security.md +1 -14
  52. package/docs/testing.md +56 -123
  53. package/package.json +4 -25
  54. package/starters/video-chat/.env.example +12 -4
  55. package/starters/video-chat/.env.native.example +13 -0
  56. package/starters/video-chat/README.md +72 -11
  57. package/starters/video-chat/package.json +1 -1
  58. package/starters/video-chat/providers/text-native.ts +94 -0
  59. package/starters/video-chat/providers/text.ts +19 -0
  60. package/starters/video-chat/providers/transcription.ts +32 -0
  61. package/starters/video-chat/providers/video-custom.ts +45 -0
  62. package/starters/video-chat/providers/video-delivery.ts +57 -0
  63. package/starters/video-chat/providers/video-google.ts +48 -0
  64. package/starters/video-chat/providers/video-job.ts +103 -0
  65. package/starters/video-chat/providers/video-runway.ts +45 -0
  66. package/starters/video-chat/providers/video.ts +39 -63
  67. package/starters/video-chat/server.ts +3 -29
  68. package/starters/video-chat/vite.config.ts +2 -8
  69. package/styles/video-chat.css +8 -105
  70. package/dist/builtin-server-W4PLJUZ5.js +0 -8
  71. package/dist/catalog-types-WTbLP6Jh.d.ts +0 -78
  72. package/dist/check-runtime.d.ts +0 -15
  73. package/dist/check-runtime.js +0 -96
  74. package/dist/chunk-2E6T633S.js +0 -27
  75. package/dist/chunk-4YM2M62S.js +0 -13
  76. package/dist/chunk-4ZJLPHBV.js +0 -684
  77. package/dist/chunk-5JBMYQP6.js +0 -156
  78. package/dist/chunk-73NTSFFI.js +0 -81
  79. package/dist/chunk-EGVQODKU.js +0 -83
  80. package/dist/chunk-HFVNAPHZ.js +0 -35
  81. package/dist/chunk-IIN5M5HW.js +0 -697
  82. package/dist/chunk-IQMYK5DX.js +0 -133
  83. package/dist/chunk-IR44XKBI.js +0 -46
  84. package/dist/chunk-JKVOBTRO.js +0 -145
  85. package/dist/chunk-LVM5Q2DL.js +0 -68
  86. package/dist/chunk-M4QTEJTK.js +0 -709
  87. package/dist/chunk-QSBDB4J2.js +0 -16
  88. package/dist/chunk-R3XAOMKP.js +0 -29
  89. package/dist/chunk-SPVTJH3F.js +0 -24
  90. package/dist/chunk-YAHT3LST.js +0 -663
  91. package/dist/chunk-ZD2VTUYR.js +0 -28
  92. package/dist/cinema-media-HYCDG65Z.js +0 -11
  93. package/dist/comparison-XXAP4S4J.js +0 -36
  94. package/dist/editorial-timeline-VUQC6KPA.js +0 -42
  95. package/dist/key-figure-4TJK7HLT.js +0 -29
  96. package/dist/kit-DrRpdn0p.d.ts +0 -79
  97. package/dist/mobile-message-FIHAO6XC.js +0 -49
  98. package/dist/preload-media-LJXWKTWG.js +0 -57
  99. package/dist/quote-GRPIYKQJ.js +0 -32
  100. package/dist/system-prompt-AG26KJAA.js +0 -12
  101. package/dist/template-catalog.d.ts +0 -400
  102. package/dist/template-catalog.js +0 -6
  103. package/dist/templates.d.ts +0 -25
  104. package/dist/templates.js +0 -102
  105. package/dist/validate-4OUD2CLX.js +0 -10
  106. package/docs/concepts.md +0 -107
  107. package/docs/custom-templates.md +0 -346
  108. package/docs/immersive-interface.md +0 -86
  109. package/docs/motion-and-effects.md +0 -109
  110. package/docs/reference/design-system.html +0 -125
  111. package/docs/responsive-orientation.md +0 -37
  112. package/docs/streaming-protocol.md +0 -15
  113. package/examples/custom-template/README.md +0 -19
  114. package/examples/custom-template/minimal-text.tsx +0 -82
  115. package/examples/custom-template/structured-data.tsx +0 -104
  116. package/registry/items/backgrounds.json +0 -70
  117. package/registry/items/chapterTitle.json +0 -80
  118. package/registry/items/cinemaMedia.json +0 -136
  119. package/registry/items/comparison.json +0 -168
  120. package/registry/items/editorialTimeline.json +0 -172
  121. package/registry/items/keyFigure.json +0 -162
  122. package/registry/items/mobileMessage.json +0 -153
  123. package/registry/items/motion.json +0 -45
  124. package/registry/items/quote.json +0 -161
  125. package/registry/items/template-context.json +0 -31
  126. package/registry/items/theme.json +0 -47
  127. package/registry/items/typography.json +0 -47
package/dist/react.js CHANGED
@@ -1,28 +1,47 @@
1
+ import {
2
+ SCENE_DEFINITIONS,
3
+ getBuiltinSceneDefinition
4
+ } from "./chunk-NM4CXXZY.js";
5
+ import {
6
+ ExternalVideoBackdropProvider
7
+ } from "./chunk-666HGTVZ.js";
1
8
  import {
2
9
  TitleSceneTemplate
3
- } from "./chunk-7AA2JWHZ.js";
4
- import "./chunk-4YM2M62S.js";
10
+ } from "./chunk-5DOQTIMD.js";
5
11
  import {
6
- getSceneDuration,
7
- getSceneDurationBounds
8
- } from "./chunk-ZD2VTUYR.js";
12
+ resolveMediaType
13
+ } from "./chunk-K5J7ESRO.js";
9
14
  import {
15
+ getSceneDurationBounds,
16
+ getSpokenDuration,
17
+ getVideoDuration,
18
+ resolveVideoTimeline
19
+ } from "./chunk-AUN4S3YW.js";
20
+ import {
21
+ CLIP_NARRATION_TAIL_SEC,
10
22
  MEDIA_RECOVERY_NOTICE,
11
23
  decodeVideoSse,
24
+ estimateNarrationSeconds,
12
25
  orderWelcomeCards,
13
- sanitizeVideoChatMedia,
14
- welcomeVisitSeed,
26
+ speechFitsClip,
27
+ welcomeVisitSeed
28
+ } from "./chunk-RFXHLLYU.js";
29
+ import {
15
30
  withDeadline
16
- } from "./chunk-44WND2VP.js";
31
+ } from "./chunk-4G4JBMCM.js";
17
32
  import {
18
33
  applyVideoEvent,
19
34
  checksumVideo,
20
35
  createVideoEventFactory,
21
36
  createVideoState
22
- } from "./chunk-RXTN2CW6.js";
37
+ } from "./chunk-Z3DLSLAJ.js";
38
+ import "./chunk-35K6IKB2.js";
23
39
  import {
24
40
  safePublicDiagnostic
25
- } from "./chunk-7M56IUUX.js";
41
+ } from "./chunk-6Z3ID54H.js";
42
+ import {
43
+ sanitizeVideoChatMedia
44
+ } from "./chunk-ISWQ6T5X.js";
26
45
  import {
27
46
  parseVideo
28
47
  } from "./chunk-AUPC6MDK.js";
@@ -30,179 +49,195 @@ import {
30
49
  VIDEO_SCHEMA_VERSION,
31
50
  validateNarrationGroups
32
51
  } from "./chunk-7NZOMTAL.js";
33
- import {
34
- getBuiltinTemplateMetadata
35
- } from "./chunk-IIN5M5HW.js";
36
- import "./chunk-3O7OMMMF.js";
37
- import {
38
- assertTemplateTransitionMetadata
39
- } from "./chunk-2E6T633S.js";
40
- import {
41
- MountedReadinessContext,
42
- VideoFrame,
43
- getDimensions,
44
- sceneReadinessKey
45
- } from "./chunk-M4QTEJTK.js";
46
- import "./chunk-5JBMYQP6.js";
47
- import "./chunk-224QNWRA.js";
48
- import {
49
- getVideoDuration,
50
- resolveVideoTimeline
51
- } from "./chunk-SPVTJH3F.js";
52
- import {
53
- markExternalVideoBackdropTemplate,
54
- supportsExternalVideoBackdrop
55
- } from "./chunk-R3XAOMKP.js";
56
- import "./chunk-73NTSFFI.js";
52
+
53
+ // src/player/mounted-scene-readiness.tsx
54
+ import { createContext, useContext, useEffect, useRef } from "react";
55
+ import { jsx } from "react/jsx-runtime";
56
+ var MountedReadinessContext = createContext(void 0);
57
+ var sceneReadinessKey = (scene) => `${scene.id}\0${String(scene.variables.mediaUrl || "")}`;
58
+ function MountedSceneReadiness({
59
+ scene,
60
+ playing,
61
+ fallback = false,
62
+ onFailure,
63
+ onReady,
64
+ observeIncoming = false,
65
+ timeoutMs = 8e3,
66
+ preparedProof
67
+ }) {
68
+ const marker = useRef(null);
69
+ const report = useContext(MountedReadinessContext);
70
+ const key = sceneReadinessKey(scene);
71
+ const onFailureRef = useRef(onFailure);
72
+ onFailureRef.current = onFailure;
73
+ const onReadyRef = useRef(onReady);
74
+ onReadyRef.current = onReady;
75
+ useEffect(() => {
76
+ if (!report && !onReadyRef.current || !playing) return;
77
+ let stopped = false;
78
+ let frame = 0;
79
+ let callback;
80
+ let observed;
81
+ let presented;
82
+ let previousMediaTime;
83
+ let forwardFrames = 0;
84
+ let motionRevision = 0;
85
+ const root = marker.current?.closest("[data-video-frame]");
86
+ const start = performance.now();
87
+ const finish = (error, actualVideoFrame = false) => {
88
+ if (stopped) return;
89
+ stopped = true;
90
+ if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
91
+ if (error && onFailureRef.current) onFailureRef.current();
92
+ else if (onReadyRef.current && !error) {
93
+ const video = presented;
94
+ const mediaTime = video?.currentTime ?? 0;
95
+ const confirmedAt = performance.now();
96
+ const source = video?.currentSrc;
97
+ const revision = motionRevision;
98
+ let consumed = false;
99
+ onReadyRef.current(actualVideoFrame && video ? { consume: (candidate) => {
100
+ const valid = !consumed && candidate === video && video.isConnected && motionRevision === revision && !video.paused && !video.seeking && video.currentSrc === source && video.currentSrc === video.src && performance.now() - confirmedAt <= 200 && video.currentTime >= mediaTime && video.currentTime - mediaTime <= 0.2;
101
+ consumed = true;
102
+ return valid;
103
+ } } : void 0);
104
+ } else report?.(key, error, actualVideoFrame);
105
+ };
106
+ const check = () => {
107
+ if (stopped) return;
108
+ if (fallback && root?.querySelector("[data-scene-fallback]") && !root.querySelector("[data-template-loading]") && document.fonts?.status !== "loading") {
109
+ finish();
110
+ return;
111
+ }
112
+ const layer = observeIncoming ? [...root?.querySelectorAll("[data-layer-scene-id]") ?? []].find((node) => node.getAttribute("data-layer-scene-id") === scene.id) : root?.querySelector('[data-scene-layer="active"]');
113
+ const loading = layer?.querySelector("[data-template-loading]");
114
+ const mediaUrl = String(scene.variables.mediaUrl || "");
115
+ const isVideo = scene.variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(mediaUrl);
116
+ if (root && layer && !loading && document.fonts?.status !== "loading") {
117
+ if (!mediaUrl || scene.variables.mediaType === "gradient") {
118
+ finish();
119
+ return;
120
+ }
121
+ if (isVideo) {
122
+ const video = layer.querySelector("video");
123
+ if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
124
+ if (preparedProof?.consume(video)) {
125
+ finish(void 0, true);
126
+ return;
127
+ }
128
+ if (observed !== video) {
129
+ previousMediaTime = void 0;
130
+ forwardFrames = 0;
131
+ }
132
+ if (presented === video && (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA || forwardFrames >= 2)) {
133
+ finish(void 0, true);
134
+ return;
135
+ }
136
+ observed = video;
137
+ if (video.requestVideoFrameCallback) {
138
+ callback = video.requestVideoFrameCallback((_now, metadata) => {
139
+ callback = void 0;
140
+ if (stopped) return;
141
+ const sameSource = video.isConnected && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src;
142
+ const mediaTime = metadata.mediaTime;
143
+ if (!sameSource || video.paused || video.seeking || !Number.isFinite(mediaTime) || previousMediaTime !== void 0 && mediaTime <= previousMediaTime + 1e-3) forwardFrames = 0;
144
+ else if (previousMediaTime !== void 0 && mediaTime > previousMediaTime + 1e-3) forwardFrames++;
145
+ previousMediaTime = sameSource && !video.paused && !video.seeking ? mediaTime : void 0;
146
+ if (sameSource) presented = video;
147
+ check();
148
+ });
149
+ return;
150
+ }
151
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
152
+ finish(void 0, true);
153
+ return;
154
+ }
155
+ }
156
+ } else {
157
+ const image = [...layer.querySelectorAll("img") ?? []].find((element) => element.getAttribute("src") === mediaUrl);
158
+ if (image?.complete && image.naturalWidth > 0) {
159
+ finish();
160
+ return;
161
+ }
162
+ }
163
+ }
164
+ if (timeoutMs !== null && performance.now() - start >= timeoutMs) {
165
+ finish(new Error("Scene media did not become ready"));
166
+ return;
167
+ }
168
+ frame = requestAnimationFrame(check);
169
+ };
170
+ const onPresented = (event) => {
171
+ const video = event.target;
172
+ if (!(video instanceof HTMLVideoElement) || stopped || video.getAttribute("src") !== String(scene.variables.mediaUrl || "") || video.currentSrc !== video.src) return;
173
+ presented = video;
174
+ cancelAnimationFrame(frame);
175
+ if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
176
+ callback = void 0;
177
+ check();
178
+ };
179
+ const resetMotion = (event) => {
180
+ if (event.target === observed || event.target === presented) {
181
+ motionRevision++;
182
+ previousMediaTime = void 0;
183
+ forwardFrames = 0;
184
+ }
185
+ };
186
+ for (const type of ["pause", "waiting", "seeking"]) root?.addEventListener(type, resetMotion, true);
187
+ root?.addEventListener("vanillasky:video-frame-presented", onPresented);
188
+ check();
189
+ const timeout = timeoutMs === null ? void 0 : setTimeout(() => finish(new Error("Scene media did not become ready")), timeoutMs);
190
+ return () => {
191
+ root?.removeEventListener("vanillasky:video-frame-presented", onPresented);
192
+ for (const type of ["pause", "waiting", "seeking"]) root?.removeEventListener(type, resetMotion, true);
193
+ stopped = true;
194
+ clearTimeout(timeout);
195
+ cancelAnimationFrame(frame);
196
+ if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
197
+ };
198
+ }, [key, report, scene, playing, fallback, observeIncoming, timeoutMs, preparedProof]);
199
+ return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
200
+ }
57
201
 
58
202
  // src/player/video-player.tsx
59
203
  import {
60
- useEffect as useEffect2,
204
+ useEffect as useEffect5,
61
205
  useMemo,
62
- useRef,
63
- useState
206
+ useRef as useRef4,
207
+ useState as useState2
64
208
  } from "react";
65
209
 
66
- // src/visual-system/catalog/player-kit.ts
67
- function createPlayerTemplateRegistry(templates) {
68
- const byId = /* @__PURE__ */ new Map();
69
- for (const template of templates) {
70
- assertTemplateTransitionMetadata(template);
71
- if (!template.defaults && !template.schema) {
72
- throw new Error(`${template.id} requires defaults or a schema`);
73
- }
74
- if (byId.has(template.id)) throw new Error(`Duplicate template id: ${template.id}`);
75
- byId.set(template.id, template);
76
- }
77
- const frozenTemplates = Object.freeze([...templates]);
78
- return Object.freeze({
79
- templates: frozenTemplates,
80
- capabilities: { templates: frozenTemplates.map(({ id }) => id) },
81
- getTemplate: (id) => byId.get(id)
82
- });
83
- }
84
- function overlayPlayerTemplateRegistry(defaults, customer) {
85
- if (!customer) return defaults;
86
- const customerTemplates = new Map(customer.templates.map((template) => [template.id, template]));
87
- const defaultIds = new Set(defaults.templates.map(({ id }) => id));
88
- return createPlayerTemplateRegistry([
89
- ...defaults.templates.map((template) => customerTemplates.get(template.id) ?? template),
90
- ...customer.templates.filter(({ id }) => !defaultIds.has(id))
91
- ]);
210
+ // src/visual-system/layout.ts
211
+ function getDimensions(orientation = "portrait") {
212
+ return orientation === "landscape" ? { width: 1920, height: 1080, aspectRatio: "16 / 9" } : { width: 1080, height: 1920, aspectRatio: "9 / 16" };
92
213
  }
93
214
 
94
- // src/visual-system/catalog/builtin-player.ts
95
- import { createElement } from "react";
96
-
97
- // src/visual-system/catalog/builtin-loaders.generated.ts
98
- var GENERATED_BUILTIN_TEMPLATE_LOADERS = {
99
- "cinemaMedia": () => import("./cinema-media-HYCDG65Z.js").then((module) => ({ default: module.MediaSceneTemplate })),
100
- "chapterTitle": () => import("./chapter-title-2JVDU62E.js").then((module) => ({ default: module.TitleSceneTemplate })),
101
- "editorialTimeline": () => import("./editorial-timeline-VUQC6KPA.js").then((module) => ({ default: module.TimelineSceneTemplate })),
102
- "mobileMessage": () => import("./mobile-message-FIHAO6XC.js").then((module) => ({ default: module.NotificationSceneTemplate })),
103
- "comparison": () => import("./comparison-XXAP4S4J.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
104
- "quote": () => import("./quote-GRPIYKQJ.js").then((module) => ({ default: module.QuoteSceneTemplate })),
105
- "keyFigure": () => import("./key-figure-4TJK7HLT.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
106
- };
107
-
108
- // src/visual-system/catalog/builtin-player.generated.ts
109
- var GENERATED_BUILTIN_PLAYER_TEMPLATES = [
110
- {
111
- "id": "cinemaMedia",
112
- "defaults": {
113
- "mediaUrl": "",
114
- "mediaType": "video",
115
- "mediaPoster": ""
116
- },
117
- "usesGlobalTransition": false
118
- },
119
- {
120
- "id": "chapterTitle",
121
- "defaults": {
122
- "title": "A different perspective"
123
- },
124
- "usesGlobalTransition": false
125
- },
126
- {
127
- "id": "editorialTimeline",
128
- "defaults": {
129
- "events": [
130
- {
131
- "label": "Observe"
132
- },
133
- {
134
- "label": "Understand"
135
- },
136
- {
137
- "label": "Act"
138
- }
139
- ],
140
- "mediaUrl": "",
141
- "mediaType": "video",
142
- "mediaPoster": ""
143
- },
144
- "usesGlobalTransition": false
145
- },
146
- {
147
- "id": "mobileMessage",
148
- "defaults": {
149
- "mediaUrl": "",
150
- "mediaType": "video",
151
- "mediaPoster": "",
152
- "message": "Can we talk?",
153
- "app": "Messages"
154
- },
155
- "usesGlobalTransition": false
156
- },
157
- {
158
- "id": "comparison",
159
- "defaults": {
160
- "leftText": "More distractions",
161
- "rightText": "More room to think",
162
- "mediaUrl": "",
163
- "mediaType": "video",
164
- "mediaPoster": ""
165
- },
166
- "usesGlobalTransition": false
167
- },
168
- {
169
- "id": "quote",
170
- "defaults": {
171
- "mediaUrl": "",
172
- "mediaType": "video",
173
- "mediaPoster": ""
174
- },
175
- "usesGlobalTransition": false
176
- },
177
- {
178
- "id": "keyFigure",
179
- "defaults": {
180
- "mediaUrl": "",
181
- "mediaType": "video",
182
- "mediaPoster": ""
183
- },
184
- "usesGlobalTransition": false
185
- }
186
- ];
187
- var GENERATED_BUILTIN_VIDEO_BACKDROP_IDS = ["cinemaMedia", "editorialTimeline", "mobileMessage", "comparison", "quote", "keyFigure"];
215
+ // src/player/video-frame.tsx
216
+ import {
217
+ createElement as createElement2,
218
+ Component,
219
+ Suspense,
220
+ useState,
221
+ useRef as useRef2,
222
+ useCallback,
223
+ useEffect as useEffect2
224
+ } from "react";
188
225
 
189
226
  // src/visual-system/catalog/builtin-player.ts
190
- function createPreloadableBuiltinTemplate(loader, mediaBackdrop) {
227
+ import { createElement } from "react";
228
+ function createRenderer(defaults, loader) {
191
229
  let loaded;
192
230
  let failure;
193
231
  let pending;
194
232
  const preload = () => {
195
233
  if (loaded) return Promise.resolve();
196
234
  if (failure) return Promise.reject(failure);
197
- pending ??= loader().then(
198
- (module) => {
199
- loaded = module;
200
- },
201
- (cause) => {
202
- failure = cause;
203
- throw cause;
204
- }
205
- );
235
+ pending ??= loader().then((module) => {
236
+ loaded = module;
237
+ }, (cause) => {
238
+ failure = cause;
239
+ throw cause;
240
+ });
206
241
  return pending;
207
242
  };
208
243
  const component = (props) => {
@@ -210,40 +245,513 @@ function createPreloadableBuiltinTemplate(loader, mediaBackdrop) {
210
245
  if (failure) throw failure;
211
246
  throw preload();
212
247
  };
213
- const template = { component, preload };
214
- return mediaBackdrop ? markExternalVideoBackdropTemplate(template) : template;
248
+ return Object.freeze({ component, defaults, preload });
215
249
  }
216
- function freezeValue(value) {
217
- if (value && typeof value === "object" && !Object.isFrozen(value)) {
218
- for (const nested of Object.values(value)) freezeValue(nested);
219
- Object.freeze(value);
220
- }
221
- return value;
250
+ var loaders = {
251
+ cinemaMedia: () => import("./cinema-media-YZ2UANJG.js").then((module) => ({ default: module.MediaSceneTemplate })),
252
+ chapterTitle: () => import("./chapter-title-2RCXX7SN.js").then((module) => ({ default: module.TitleSceneTemplate }))
253
+ };
254
+ var renderers = new Map(SCENE_DEFINITIONS.map((scene) => [
255
+ scene.id,
256
+ createRenderer(scene.defaults, loaders[scene.id])
257
+ ]));
258
+ function getBuiltinSceneRenderer(id) {
259
+ return renderers.get(id);
222
260
  }
223
- var preloadableById = new Map(
224
- GENERATED_BUILTIN_PLAYER_TEMPLATES.map(({ id }) => [
225
- id,
226
- createPreloadableBuiltinTemplate(GENERATED_BUILTIN_TEMPLATE_LOADERS[id], GENERATED_BUILTIN_VIDEO_BACKDROP_IDS.includes(id))
227
- ])
228
- );
229
- var builtinPlayerTemplates = Object.freeze(
230
- GENERATED_BUILTIN_PLAYER_TEMPLATES.map((template) => Object.freeze({
231
- ...template,
232
- defaults: freezeValue({ ...template.defaults }),
233
- component: preloadableById.get(template.id).component
234
- }))
235
- );
236
- var BUILTIN_PLAYER_KIT = createPlayerTemplateRegistry(builtinPlayerTemplates);
237
261
  function preloadBuiltinTemplate(id) {
238
- return preloadableById.get(id)?.preload();
262
+ return getBuiltinSceneRenderer(id)?.preload();
263
+ }
264
+
265
+ // src/player/video-frame.tsx
266
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
267
+ function PresentedScene({ notify }) {
268
+ useEffect2(() => {
269
+ if (!notify) return;
270
+ const frame = requestAnimationFrame(() => {
271
+ try {
272
+ void Promise.resolve(notify()).catch(() => void 0);
273
+ } catch {
274
+ }
275
+ });
276
+ return () => cancelAnimationFrame(frame);
277
+ }, [notify]);
278
+ return null;
279
+ }
280
+ function SafeScene({ scene, onFramePresented }) {
281
+ const copy = scene.narration?.trim() || Object.values(scene.variables).filter((value) => typeof value === "string" && !/^https?:/i.test(value)).join(" ");
282
+ return /* @__PURE__ */ jsxs(
283
+ "div",
284
+ {
285
+ "data-scene-fallback": "true",
286
+ style: {
287
+ width: "100%",
288
+ height: "100%",
289
+ display: "grid",
290
+ placeContent: "center",
291
+ boxSizing: "border-box",
292
+ padding: "8%",
293
+ background: "#090712",
294
+ color: "#fff",
295
+ font: "500 clamp(20px, 4vw, 64px)/1.3 system-ui",
296
+ overflow: "hidden"
297
+ },
298
+ children: [
299
+ /* @__PURE__ */ jsx2(MountedSceneReadiness, { scene, playing: true, fallback: true }),
300
+ /* @__PURE__ */ jsx2(PresentedScene, { notify: onFramePresented }),
301
+ /* @__PURE__ */ jsx2("p", { children: copy.slice(0, 600) || "Your response continues." }),
302
+ /* @__PURE__ */ jsx2("small", { style: { fontSize: "0.3em" }, role: "status", children: "This scene uses a simpler layout." })
303
+ ]
304
+ }
305
+ );
306
+ }
307
+ var SceneBoundary = class extends Component {
308
+ state = { failed: false };
309
+ static getDerivedStateFromError() {
310
+ return { failed: true };
311
+ }
312
+ render() {
313
+ return this.state.failed ? /* @__PURE__ */ jsx2(SafeScene, { scene: this.props.scene, onFramePresented: this.props.onFramePresented }) : this.props.children;
314
+ }
315
+ };
316
+ var CONTIGUITY_ULP_FACTOR = 4;
317
+ function clamp01(value) {
318
+ return Math.max(0, Math.min(1, value));
319
+ }
320
+ function rangesAreContiguous(left, right) {
321
+ const scale = Math.max(1, Math.abs(left.end), Math.abs(right.start));
322
+ const ulpTolerance = Number.EPSILON * scale * CONTIGUITY_ULP_FACTOR;
323
+ return Math.abs(left.end - right.start) <= ulpTolerance;
324
+ }
325
+ function sceneHasBackdrop(range) {
326
+ return String(range.scene.variables.mediaType || "auto") !== "gradient" && String(range.scene.variables.mediaUrl || "").trim() !== "";
327
+ }
328
+ function sceneHasVideoBackdrop(range) {
329
+ const mediaUrl = String(range.scene.variables.mediaUrl || "").trim();
330
+ return mediaUrl !== "" && resolveMediaType(
331
+ String(range.scene.variables.mediaType || "auto"),
332
+ mediaUrl
333
+ ) === "video";
334
+ }
335
+ function SceneLayer({
336
+ onFramePresented,
337
+ onMediaError,
338
+ range,
339
+ progress,
340
+ motionProgress,
341
+ width,
342
+ height,
343
+ playing,
344
+ preparingNarration,
345
+ mediaAudioMuted,
346
+ mediaAudioVolume,
347
+ layer,
348
+ opacity,
349
+ interactive,
350
+ zIndex,
351
+ externalVideoBackdrop
352
+ }) {
353
+ const mediaFailed = externalVideoBackdrop === "fallback" || range.scene.templateId === "cinemaMedia" && !String(range.scene.variables.mediaUrl || "").trim();
354
+ const recoveryTitle = mediaFailed && range.scene.templateId === "cinemaMedia" ? typeof range.scene.variables.fallbackText === "string" && range.scene.variables.fallbackText.trim() || "Your response continues." : void 0;
355
+ const template = getBuiltinSceneRenderer(recoveryTitle ? "chapterTitle" : range.scene.templateId);
356
+ const duration = range.end - range.start;
357
+ return /* @__PURE__ */ jsx2(
358
+ ExternalVideoBackdropProvider,
359
+ {
360
+ mode: externalVideoBackdrop,
361
+ audioMuted: mediaAudioMuted || !playing,
362
+ audioVolume: mediaAudioVolume,
363
+ preparingNarration,
364
+ onMediaError,
365
+ children: /* @__PURE__ */ jsxs(
366
+ "div",
367
+ {
368
+ "data-scene-layer": layer,
369
+ "data-scene-fallback": mediaFailed ? "true" : void 0,
370
+ "data-layer-scene-id": range.scene.id,
371
+ "data-layer-template-id": range.scene.templateId,
372
+ "aria-hidden": interactive ? void 0 : true,
373
+ ...interactive ? {} : { inert: "inert" },
374
+ style: {
375
+ position: "absolute",
376
+ inset: 0,
377
+ opacity,
378
+ zIndex,
379
+ pointerEvents: interactive ? "auto" : "none"
380
+ },
381
+ children: [
382
+ range.scene.templateId === "cinemaMedia" && !recoveryTitle && (mediaFailed || !String(range.scene.variables.mediaUrl || "").trim()) && /* @__PURE__ */ jsx2(
383
+ "div",
384
+ {
385
+ role: "status",
386
+ "data-media-unavailable": "true",
387
+ style: { position: "absolute", inset: 0, zIndex: 2, display: "grid", placeContent: "center", color: "#bbb", font: "14px system-ui", background: "#000" },
388
+ children: "Visual unavailable"
389
+ }
390
+ ),
391
+ template ? /* @__PURE__ */ jsx2(SceneBoundary, { scene: range.scene, onFramePresented, children: /* @__PURE__ */ jsxs(Suspense, { fallback: /* @__PURE__ */ jsx2(
392
+ "div",
393
+ {
394
+ "data-template-loading": range.scene.templateId,
395
+ style: { position: "absolute", inset: 0 }
396
+ }
397
+ ), children: [
398
+ /* @__PURE__ */ jsx2(PresentedScene, { notify: onFramePresented }),
399
+ createElement2(template.component, {
400
+ variables: {
401
+ ...template.defaults,
402
+ ...range.scene.variables,
403
+ ...mediaFailed ? { mediaUrl: "", mediaPoster: "", mediaType: "gradient" } : {},
404
+ ...recoveryTitle ? { title: recoveryTitle } : {}
405
+ },
406
+ progress,
407
+ motionProgress,
408
+ width,
409
+ height,
410
+ sceneDuration: duration,
411
+ isPlaying: playing
412
+ })
413
+ ] }) }, range.scene.id) : /* @__PURE__ */ jsx2(SafeScene, { scene: range.scene, onFramePresented })
414
+ ]
415
+ }
416
+ )
417
+ }
418
+ );
419
+ }
420
+ function VideoFrame({
421
+ onFramePresented,
422
+ config,
423
+ time,
424
+ width,
425
+ height,
426
+ playing = false,
427
+ preparingNarration = false,
428
+ mediaAudioMuted = true,
429
+ mediaAudioVolume = 1,
430
+ className,
431
+ style
432
+ }) {
433
+ const recoveryRoot = useRef2(null);
434
+ const displayedKey = useRef2(void 0);
435
+ const displayedWasPlaying = useRef2(false);
436
+ const handoffProof = useRef2(void 0);
437
+ const confirmedHandoff = useRef2(void 0);
438
+ const [preparedMedia, setPreparedMedia] = useState(() => /* @__PURE__ */ new Set());
439
+ const markPrepared = useCallback((key) => setPreparedMedia((previous2) => /* @__PURE__ */ new Set([...previous2, key])), []);
440
+ const reportedFailures = useRef2(/* @__PURE__ */ new Set());
441
+ const [failedMedia, setFailedMedia] = useState(() => /* @__PURE__ */ new Set());
442
+ const markMediaFailed = useCallback((key, reason = "playback-error") => {
443
+ if (!key || !config.scenes.some((scene) => sceneReadinessKey(scene) === key)) return;
444
+ if (!reportedFailures.current.has(key)) {
445
+ reportedFailures.current.add(key);
446
+ recoveryRoot.current?.dispatchEvent(new CustomEvent("vanillasky:media-recovery", { bubbles: true, detail: { reason: ["decode-error", "frame-readiness-timeout", "stalled-media", "playback-error", "duration-mismatch"].includes(reason) ? reason : "playback-error" } }));
447
+ }
448
+ setFailedMedia((previous2) => previous2.has(key) ? previous2 : /* @__PURE__ */ new Set([...previous2, key]));
449
+ }, [config.scenes]);
450
+ useEffect2(() => {
451
+ const currentKeys = new Set(config.scenes.map(sceneReadinessKey));
452
+ setPreparedMedia((previous2) => {
453
+ const retained = new Set([...previous2].filter((key) => currentKeys.has(key)));
454
+ return retained.size === previous2.size ? previous2 : retained;
455
+ });
456
+ for (const key of reportedFailures.current) if (!currentKeys.has(key)) reportedFailures.current.delete(key);
457
+ setFailedMedia((previous2) => {
458
+ const retained = new Set([...previous2].filter((key) => currentKeys.has(key)));
459
+ return retained.size === previous2.size ? previous2 : retained;
460
+ });
461
+ }, [config.scenes]);
462
+ const timeline = resolveVideoTimeline(config);
463
+ const lastRange = timeline.at(-1);
464
+ const foundIndex = timeline.findIndex((range) => time >= range.start && time < range.end);
465
+ const afterEnd = lastRange && time >= lastRange.end;
466
+ const targetIndex = foundIndex >= 0 ? foundIndex : afterEnd ? timeline.length - 1 : -1;
467
+ const target = timeline[targetIndex];
468
+ const targetKey = target ? sceneReadinessKey(target.scene) : void 0;
469
+ if (confirmedHandoff.current !== targetKey || !(playing || preparingNarration)) {
470
+ confirmedHandoff.current = void 0;
471
+ handoffProof.current = void 0;
472
+ }
473
+ const previousIndex = timeline.findIndex((range) => sceneReadinessKey(range.scene) === displayedKey.current);
474
+ const previous = timeline[previousIndex];
475
+ const canPrepare = (range) => Boolean(range && (mediaAudioMuted || !sceneHasVideoBackdrop(range)) && sceneHasBackdrop(range) && range.scene.templateId === "cinemaMedia");
476
+ const canRetain = (range) => canPrepare(range) || range?.scene.templateId === "chapterTitle";
477
+ const hasPlayableMedia = (range) => {
478
+ if (!range || !preparedMedia.has(sceneReadinessKey(range.scene))) return false;
479
+ const layer = [...recoveryRoot.current?.querySelectorAll("[data-layer-scene-id]") ?? []].find((node) => node.getAttribute("data-layer-scene-id") === range.scene.id);
480
+ if (!sceneHasVideoBackdrop(range)) {
481
+ const image = [...layer?.querySelectorAll("img") ?? []].find((element) => element.getAttribute("src") === range.scene.variables.mediaUrl);
482
+ return Boolean(image && image.getAttribute("src") === range.scene.variables.mediaUrl && image.complete && image.naturalWidth > 0);
483
+ }
484
+ const video = layer?.querySelector("video");
485
+ return Boolean(video && video.getAttribute("src") === range.scene.variables.mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA);
486
+ };
487
+ const handoffPending = Boolean(displayedWasPlaying.current && target && previous && targetIndex === previousIndex + 1 && time <= target.start + 1e-3 && rangesAreContiguous(previous, target) && canRetain(previous) && canPrepare(target) && !failedMedia.has(sceneReadinessKey(target.scene)) && !hasPlayableMedia(target) && confirmedHandoff.current !== targetKey);
488
+ const activeIndex = handoffPending ? previousIndex : targetIndex;
489
+ const active = timeline[activeIndex];
490
+ const displayKey = active ? sceneReadinessKey(active.scene) : void 0;
491
+ displayedWasPlaying.current = displayedKey.current === displayKey && displayedWasPlaying.current || playing || preparingNarration;
492
+ displayedKey.current = displayKey;
493
+ if (!active) {
494
+ return /* @__PURE__ */ jsx2(
495
+ "div",
496
+ {
497
+ "data-video-frame": timeline.length === 0 ? "empty" : "gap",
498
+ className,
499
+ style: { width, height, background: "#000", ...style }
500
+ }
501
+ );
502
+ }
503
+ if (!getBuiltinSceneRenderer(active.scene.templateId)) {
504
+ return /* @__PURE__ */ jsx2(
505
+ "div",
506
+ {
507
+ "data-video-frame": "unsupported",
508
+ "data-template-id": active.scene.templateId,
509
+ className,
510
+ style: { width, height, background: "#090712", color: "#fff", ...style },
511
+ children: /* @__PURE__ */ jsx2(SafeScene, { scene: active.scene, onFramePresented })
512
+ }
513
+ );
514
+ }
515
+ const duration = active.end - active.start;
516
+ const rawProgress = clamp01((time - active.start) / duration);
517
+ const next = activeIndex < timeline.length - 1 ? timeline[activeIndex + 1] : void 0;
518
+ const contiguousNext = next && rangesAreContiguous(active, next) ? next : void 0;
519
+ const activeMediaFailed = failedMedia.has(sceneReadinessKey(active.scene));
520
+ const preparingNext = canRetain(active) && canPrepare(contiguousNext);
521
+ const nextPlayable = hasPlayableMedia(contiguousNext);
522
+ const mountingNext = handoffPending || Boolean(contiguousNext && time < active.end && sceneHasBackdrop(contiguousNext));
523
+ const progress = rawProgress;
524
+ const isFinalScene = activeIndex === timeline.length - 1;
525
+ const presentsChapter = active.scene.templateId === "chapterTitle" || active.scene.templateId === "cinemaMedia" && (activeMediaFailed || !String(active.scene.variables.mediaUrl || "").trim());
526
+ const finalHold = presentsChapter ? 0.76 : void 0;
527
+ const holdChapter = presentsChapter && preparingNext && !nextPlayable;
528
+ const motionProgress = (isFinalScene || holdChapter) && finalHold !== void 0 ? Math.min(rawProgress, finalHold) : rawProgress;
529
+ const canvas = getDimensions(config.orientation);
530
+ const scale = Math.min(width / canvas.width, height / canvas.height);
531
+ const canvasLeft = (width - canvas.width * scale) / 2;
532
+ const canvasTop = (height - canvas.height * scale) / 2;
533
+ return /* @__PURE__ */ jsxs(
534
+ "div",
535
+ {
536
+ ref: recoveryRoot,
537
+ onErrorCapture: (event) => {
538
+ const target2 = event.target;
539
+ if (!(target2 instanceof HTMLVideoElement || target2 instanceof HTMLImageElement)) return;
540
+ const ownerId = target2.closest("[data-layer-scene-id]")?.getAttribute("data-layer-scene-id");
541
+ const owner = [active, contiguousNext].find((range) => range?.scene.id === ownerId);
542
+ if (owner?.scene.templateId === "cinemaMedia" && target2.getAttribute("src") === owner.scene.variables.mediaUrl) {
543
+ markMediaFailed(sceneReadinessKey(owner.scene), "decode-error");
544
+ }
545
+ },
546
+ "data-video-frame": "ready",
547
+ "data-scene-id": active.scene.id,
548
+ "data-template-id": active.scene.templateId,
549
+ className,
550
+ style: {
551
+ width,
552
+ height,
553
+ position: "relative",
554
+ overflow: "hidden",
555
+ background: "#000",
556
+ ...style
557
+ },
558
+ children: [
559
+ /* @__PURE__ */ jsx2(
560
+ MountedSceneReadiness,
561
+ {
562
+ scene: active.scene,
563
+ playing: playing && !handoffPending,
564
+ fallback: activeMediaFailed,
565
+ preparedProof: confirmedHandoff.current === sceneReadinessKey(active.scene) ? handoffProof.current : void 0,
566
+ onFailure: sceneHasBackdrop(active) && active.scene.templateId === "cinemaMedia" && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene), "frame-readiness-timeout") : void 0
567
+ }
568
+ ),
569
+ mountingNext && contiguousNext && preparingNext && /* @__PURE__ */ jsx2(
570
+ MountedSceneReadiness,
571
+ {
572
+ scene: contiguousNext.scene,
573
+ playing: playing || preparingNarration,
574
+ observeIncoming: true,
575
+ timeoutMs: handoffPending ? 8e3 : null,
576
+ onReady: (proof) => {
577
+ const key = sceneReadinessKey(contiguousNext.scene);
578
+ if (handoffPending) {
579
+ confirmedHandoff.current = key;
580
+ handoffProof.current = proof;
581
+ }
582
+ markPrepared(key);
583
+ },
584
+ onFailure: () => markMediaFailed(sceneReadinessKey(contiguousNext.scene), "frame-readiness-timeout")
585
+ }
586
+ ),
587
+ /* @__PURE__ */ jsxs(
588
+ "div",
589
+ {
590
+ "data-video-canvas": "true",
591
+ style: {
592
+ position: "absolute",
593
+ left: canvasLeft,
594
+ top: canvasTop,
595
+ width: canvas.width,
596
+ height: canvas.height,
597
+ transform: `scale(${scale})`,
598
+ transformOrigin: "top left"
599
+ },
600
+ children: [
601
+ /* @__PURE__ */ jsx2(
602
+ "div",
603
+ {
604
+ "data-player-background": "black",
605
+ "aria-hidden": "true",
606
+ style: {
607
+ position: "absolute",
608
+ inset: 0,
609
+ background: "#000",
610
+ backgroundColor: "#000",
611
+ pointerEvents: "none"
612
+ }
613
+ }
614
+ ),
615
+ mountingNext && contiguousNext ? [
616
+ /* @__PURE__ */ jsx2(
617
+ SceneLayer,
618
+ {
619
+ onFramePresented,
620
+ range: active,
621
+ onMediaError: (reason) => markMediaFailed(sceneReadinessKey(active.scene), reason),
622
+ progress,
623
+ motionProgress,
624
+ width: canvas.width,
625
+ height: canvas.height,
626
+ playing: handoffPending ? playing || preparingNarration : playing,
627
+ preparingNarration: handoffPending ? false : preparingNarration,
628
+ mediaAudioMuted,
629
+ mediaAudioVolume,
630
+ layer: "active",
631
+ opacity: 1,
632
+ interactive: true,
633
+ zIndex: 1,
634
+ externalVideoBackdrop: activeMediaFailed ? "fallback" : false
635
+ },
636
+ active.scene.id
637
+ ),
638
+ /* @__PURE__ */ jsx2(
639
+ SceneLayer,
640
+ {
641
+ range: contiguousNext,
642
+ progress: 0,
643
+ motionProgress: 0,
644
+ width: canvas.width,
645
+ height: canvas.height,
646
+ playing: Boolean(preparingNext && (!preparedMedia.has(sceneReadinessKey(contiguousNext.scene)) || handoffPending) && (playing || preparingNarration)),
647
+ preparingNarration: preparingNext,
648
+ mediaAudioMuted: true,
649
+ mediaAudioVolume,
650
+ layer: "incoming",
651
+ opacity: 0,
652
+ interactive: false,
653
+ zIndex: 2,
654
+ onMediaError: (reason) => markMediaFailed(sceneReadinessKey(contiguousNext.scene), reason),
655
+ externalVideoBackdrop: failedMedia.has(sceneReadinessKey(contiguousNext.scene)) ? "fallback" : false
656
+ },
657
+ contiguousNext.scene.id
658
+ )
659
+ ] : [
660
+ /* @__PURE__ */ jsx2(
661
+ SceneLayer,
662
+ {
663
+ onFramePresented,
664
+ range: active,
665
+ onMediaError: (reason) => markMediaFailed(sceneReadinessKey(active.scene), reason),
666
+ progress,
667
+ motionProgress,
668
+ width: canvas.width,
669
+ height: canvas.height,
670
+ playing,
671
+ preparingNarration,
672
+ mediaAudioMuted,
673
+ mediaAudioVolume,
674
+ layer: "active",
675
+ opacity: 1,
676
+ interactive: true,
677
+ zIndex: 1,
678
+ externalVideoBackdrop: activeMediaFailed ? "fallback" : false
679
+ },
680
+ active.scene.id
681
+ )
682
+ ]
683
+ ]
684
+ }
685
+ )
686
+ ]
687
+ }
688
+ );
239
689
  }
240
690
 
241
691
  // src/player/warm-scene-media.ts
242
- function warmSceneMedia(variables) {
243
- void import("./preload-media-LJXWKTWG.js").then((module) => module.preloadSceneMedia(variables)).catch(() => {
692
+ function warmSceneMedia(variables, signal) {
693
+ void import("./preload-media-23JXUFCF.js").then((module) => module.preloadSceneMedia(variables, signal)).catch(() => {
244
694
  });
245
695
  }
246
696
 
697
+ // src/player/use-playback-diagnostics.ts
698
+ import { useEffect as useEffect3, useRef as useRef3 } from "react";
699
+ function usePlaybackDiagnostics(root, config, report) {
700
+ const current = useRef3({ config, report });
701
+ current.current = { config, report };
702
+ const enabled = Boolean(report);
703
+ useEffect3(() => {
704
+ if (!enabled) return;
705
+ const observed = /* @__PURE__ */ new WeakMap();
706
+ let lastBuffer = -1;
707
+ const emit = (metric) => {
708
+ try {
709
+ void Promise.resolve(current.current.report?.(metric)).catch(() => void 0);
710
+ } catch {
711
+ }
712
+ };
713
+ const sample = () => {
714
+ const video = current.current.config;
715
+ if (!root.current || !video) return;
716
+ const ranges = resolveVideoTimeline(video);
717
+ let bufferedSeconds = 0;
718
+ for (const media of root.current.querySelectorAll("video")) {
719
+ const layer = media.closest("[data-layer-scene-id]");
720
+ const sceneId = layer?.getAttribute("data-layer-scene-id");
721
+ const range = ranges.find((value) => value.scene.id === sceneId);
722
+ if (!range || !Number.isFinite(media.duration) || media.duration <= 0) continue;
723
+ const active = layer?.getAttribute("data-scene-layer") === "active";
724
+ const previous = observed.get(media);
725
+ const same = previous?.sceneId === sceneId;
726
+ const repeated = same && previous && !media.paused && !media.seeking && media.currentTime < previous.time - 0.5;
727
+ const repeats = (same && previous ? previous.repeats : 0) + (repeated ? 1 : 0);
728
+ if (active && (!same || repeated)) emit({
729
+ type: "media-playback",
730
+ clipDurationSec: media.duration,
731
+ sceneDurationSec: range.end - range.start,
732
+ repeatCount: repeats
733
+ });
734
+ if (active) observed.set(media, { time: media.currentTime, repeats, sceneId });
735
+ if (media.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) continue;
736
+ for (let index = 0; index < media.buffered.length; index++) {
737
+ if (media.buffered.start(index) <= media.currentTime && media.buffered.end(index) >= media.currentTime) {
738
+ bufferedSeconds += Math.max(0, Math.min(media.buffered.end(index), range.end - range.start) - media.currentTime);
739
+ break;
740
+ }
741
+ }
742
+ }
743
+ bufferedSeconds = Math.round(bufferedSeconds * 10) / 10;
744
+ if (bufferedSeconds !== lastBuffer) {
745
+ lastBuffer = bufferedSeconds;
746
+ emit({ type: "buffer", bufferedSeconds });
747
+ }
748
+ };
749
+ sample();
750
+ const timer = setInterval(sample, 500);
751
+ return () => clearInterval(timer);
752
+ }, [enabled, root]);
753
+ }
754
+
247
755
  // src/player/playback-policy.ts
248
756
  function resolvePlaybackPolicy({
249
757
  playbackMode,
@@ -263,7 +771,7 @@ function resolvePlaybackPolicy({
263
771
  }
264
772
 
265
773
  // src/player/player-controls.tsx
266
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
774
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
267
775
  function PlayerIcon({ name, size = 22 }) {
268
776
  const shared = {
269
777
  width: size,
@@ -276,33 +784,33 @@ function PlayerIcon({ name, size = 22 }) {
276
784
  strokeLinejoin: "round"
277
785
  };
278
786
  if (name === "play") {
279
- return /* @__PURE__ */ jsx("svg", { ...shared, children: /* @__PURE__ */ jsx("path", { d: "M8 5v14l11-7z", fill: "currentColor", stroke: "none" }) });
787
+ return /* @__PURE__ */ jsx3("svg", { ...shared, children: /* @__PURE__ */ jsx3("path", { d: "M8 5v14l11-7z", fill: "currentColor", stroke: "none" }) });
280
788
  }
281
789
  if (name === "pause") {
282
- return /* @__PURE__ */ jsx("svg", { ...shared, children: /* @__PURE__ */ jsx("path", { d: "M7 5h4v14H7zm6 0h4v14h-4z", fill: "currentColor", stroke: "none" }) });
790
+ return /* @__PURE__ */ jsx3("svg", { ...shared, children: /* @__PURE__ */ jsx3("path", { d: "M7 5h4v14H7zm6 0h4v14h-4z", fill: "currentColor", stroke: "none" }) });
283
791
  }
284
792
  if (name === "replay") {
285
- return /* @__PURE__ */ jsxs("svg", { ...shared, children: [
286
- /* @__PURE__ */ jsx("path", { d: "M4.5 9A8 8 0 1 1 5 16" }),
287
- /* @__PURE__ */ jsx("path", { d: "M4.5 4.5V9H9" })
793
+ return /* @__PURE__ */ jsxs2("svg", { ...shared, children: [
794
+ /* @__PURE__ */ jsx3("path", { d: "M4.5 9A8 8 0 1 1 5 16" }),
795
+ /* @__PURE__ */ jsx3("path", { d: "M4.5 4.5V9H9" })
288
796
  ] });
289
797
  }
290
798
  if (name === "volume-off") {
291
- return /* @__PURE__ */ jsxs("svg", { ...shared, children: [
292
- /* @__PURE__ */ jsx("path", { d: "M11 5 6.5 9H3v6h3.5L11 19z" }),
293
- /* @__PURE__ */ jsx("path", { d: "m16 9 5 5M21 9l-5 5" })
799
+ return /* @__PURE__ */ jsxs2("svg", { ...shared, children: [
800
+ /* @__PURE__ */ jsx3("path", { d: "M11 5 6.5 9H3v6h3.5L11 19z" }),
801
+ /* @__PURE__ */ jsx3("path", { d: "m16 9 5 5M21 9l-5 5" })
294
802
  ] });
295
803
  }
296
804
  if (name === "volume") {
297
- return /* @__PURE__ */ jsxs("svg", { ...shared, children: [
298
- /* @__PURE__ */ jsx("path", { d: "M11 5 6.5 9H3v6h3.5L11 19z" }),
299
- /* @__PURE__ */ jsx("path", { d: "M15 9.5a4 4 0 0 1 0 5M17.8 7a7.5 7.5 0 0 1 0 10" })
805
+ return /* @__PURE__ */ jsxs2("svg", { ...shared, children: [
806
+ /* @__PURE__ */ jsx3("path", { d: "M11 5 6.5 9H3v6h3.5L11 19z" }),
807
+ /* @__PURE__ */ jsx3("path", { d: "M15 9.5a4 4 0 0 1 0 5M17.8 7a7.5 7.5 0 0 1 0 10" })
300
808
  ] });
301
809
  }
302
810
  if (name === "exit-fullscreen") {
303
- return /* @__PURE__ */ jsx("svg", { ...shared, children: /* @__PURE__ */ jsx("path", { d: "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" }) });
811
+ return /* @__PURE__ */ jsx3("svg", { ...shared, children: /* @__PURE__ */ jsx3("path", { d: "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" }) });
304
812
  }
305
- return /* @__PURE__ */ jsx("svg", { ...shared, children: /* @__PURE__ */ jsx("path", { d: "M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" }) });
813
+ return /* @__PURE__ */ jsx3("svg", { ...shared, children: /* @__PURE__ */ jsx3("path", { d: "M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" }) });
306
814
  }
307
815
  var startButtonStyle = {
308
816
  display: "inline-flex",
@@ -341,7 +849,7 @@ function GenerationCover({
341
849
  onStart
342
850
  }) {
343
851
  if (!visible) return null;
344
- return /* @__PURE__ */ jsxs(
852
+ return /* @__PURE__ */ jsxs2(
345
853
  "div",
346
854
  {
347
855
  "data-testid": "video-generation-cover",
@@ -359,12 +867,12 @@ function GenerationCover({
359
867
  fontFamily: '-apple-system, BlinkMacSystemFont, "Helvetica Neue", Roboto, Arial, sans-serif'
360
868
  },
361
869
  children: [
362
- /* @__PURE__ */ jsxs("div", { style: { position: "absolute", top: "28%", left: "10%", right: "10%" }, children: [
363
- /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { fontSize: "clamp(28px, 8vw, 72px)", opacity: 0.9 }, children: "\u2726" }),
364
- /* @__PURE__ */ jsx("strong", { style: { display: "block", marginTop: 18, fontSize: "clamp(24px, 5vw, 56px)", lineHeight: 1.08 }, children: "Creating your video\u2026" }),
365
- /* @__PURE__ */ jsx("span", { style: { display: "block", marginTop: 14, fontSize: "clamp(14px, 2vw, 22px)", lineHeight: 1.4, opacity: 0.78 }, children: "Choosing the best scenes for your content." })
870
+ /* @__PURE__ */ jsxs2("div", { style: { position: "absolute", top: "28%", left: "10%", right: "10%" }, children: [
871
+ /* @__PURE__ */ jsx3("div", { "aria-hidden": "true", style: { fontSize: "clamp(28px, 8vw, 72px)", opacity: 0.9 }, children: "\u2726" }),
872
+ /* @__PURE__ */ jsx3("strong", { style: { display: "block", marginTop: 18, fontSize: "clamp(24px, 5vw, 56px)", lineHeight: 1.08 }, children: "Creating your video\u2026" }),
873
+ /* @__PURE__ */ jsx3("span", { style: { display: "block", marginTop: 14, fontSize: "clamp(14px, 2vw, 22px)", lineHeight: 1.4, opacity: 0.78 }, children: "Choosing the best scenes for your content." })
366
874
  ] }),
367
- !startRequested && !isPlaying ? /* @__PURE__ */ jsxs(
875
+ !startRequested && !isPlaying ? /* @__PURE__ */ jsxs2(
368
876
  "button",
369
877
  {
370
878
  type: "button",
@@ -372,7 +880,7 @@ function GenerationCover({
372
880
  onClick: onStart,
373
881
  style: { ...startButtonStyle, ...startButtonPositionStyle },
374
882
  children: [
375
- /* @__PURE__ */ jsx(PlayerIcon, { name: "play", size: 20 }),
883
+ /* @__PURE__ */ jsx3(PlayerIcon, { name: "play", size: 20 }),
376
884
  playLabel(config, isMuted)
377
885
  ]
378
886
  }
@@ -388,7 +896,7 @@ function StartPosterButton({
388
896
  onStart
389
897
  }) {
390
898
  if (!visible) return null;
391
- return /* @__PURE__ */ jsxs(
899
+ return /* @__PURE__ */ jsxs2(
392
900
  "button",
393
901
  {
394
902
  type: "button",
@@ -397,7 +905,7 @@ function StartPosterButton({
397
905
  onClick: onStart,
398
906
  style: { ...startButtonStyle, ...startButtonPositionStyle },
399
907
  children: [
400
- /* @__PURE__ */ jsx(PlayerIcon, { name: "play", size: 20 }),
908
+ /* @__PURE__ */ jsx3(PlayerIcon, { name: "play", size: 20 }),
401
909
  playLabel(config, isMuted)
402
910
  ]
403
911
  }
@@ -405,8 +913,8 @@ function StartPosterButton({
405
913
  }
406
914
  function EndedOverlay({ ended, onReplay }) {
407
915
  if (!ended) return null;
408
- return /* @__PURE__ */ jsxs(Fragment, { children: [
409
- /* @__PURE__ */ jsx(
916
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
917
+ /* @__PURE__ */ jsx3(
410
918
  "div",
411
919
  {
412
920
  "data-testid": "video-ended-scrim",
@@ -421,7 +929,7 @@ function EndedOverlay({ ended, onReplay }) {
421
929
  }
422
930
  }
423
931
  ),
424
- /* @__PURE__ */ jsxs(
932
+ /* @__PURE__ */ jsxs2(
425
933
  "button",
426
934
  {
427
935
  type: "button",
@@ -430,7 +938,7 @@ function EndedOverlay({ ended, onReplay }) {
430
938
  onClick: onReplay,
431
939
  style: { ...startButtonStyle, ...startButtonPositionStyle, zIndex: 3 },
432
940
  children: [
433
- /* @__PURE__ */ jsx(PlayerIcon, { name: "replay", size: 21 }),
941
+ /* @__PURE__ */ jsx3(PlayerIcon, { name: "replay", size: 21 }),
434
942
  "Replay"
435
943
  ]
436
944
  }
@@ -468,36 +976,36 @@ function PlayerControls({
468
976
  backdropFilter: "blur(12px)",
469
977
  cursor: "pointer"
470
978
  };
471
- return /* @__PURE__ */ jsxs(
979
+ return /* @__PURE__ */ jsxs2(
472
980
  "div",
473
981
  {
474
982
  "data-testid": "video-controls",
475
983
  style: { position: "absolute", inset: 0, zIndex: 4, pointerEvents: "none" },
476
984
  children: [
477
- /* @__PURE__ */ jsx(
985
+ /* @__PURE__ */ jsx3(
478
986
  "div",
479
987
  {
480
988
  "data-testid": "video-primary-controls",
481
989
  style: { position: "absolute", left: inset, bottom: inset, display: "flex", pointerEvents: "auto" },
482
- children: /* @__PURE__ */ jsx(
990
+ children: /* @__PURE__ */ jsx3(
483
991
  "button",
484
992
  {
485
993
  type: "button",
486
994
  "aria-label": ended ? "Play video response from beginning" : isPlaying ? "Pause video response" : "Play video response",
487
995
  onClick: onTogglePlayback,
488
996
  style: buttonStyle,
489
- children: /* @__PURE__ */ jsx(PlayerIcon, { name: isPlaying ? "pause" : "play" })
997
+ children: /* @__PURE__ */ jsx3(PlayerIcon, { name: isPlaying ? "pause" : "play" })
490
998
  }
491
999
  )
492
1000
  }
493
1001
  ),
494
- /* @__PURE__ */ jsxs(
1002
+ /* @__PURE__ */ jsxs2(
495
1003
  "div",
496
1004
  {
497
1005
  "data-testid": "video-secondary-controls",
498
1006
  style: { position: "absolute", right: inset, bottom: inset, display: "flex", gap: 10, pointerEvents: "auto" },
499
1007
  children: [
500
- hasAudio ? /* @__PURE__ */ jsx(
1008
+ hasAudio ? /* @__PURE__ */ jsx3(
501
1009
  "button",
502
1010
  {
503
1011
  type: "button",
@@ -505,17 +1013,17 @@ function PlayerControls({
505
1013
  "aria-pressed": !isMuted,
506
1014
  onClick: onToggleMuted,
507
1015
  style: buttonStyle,
508
- children: /* @__PURE__ */ jsx(PlayerIcon, { name: isMuted ? "volume-off" : "volume" })
1016
+ children: /* @__PURE__ */ jsx3(PlayerIcon, { name: isMuted ? "volume-off" : "volume" })
509
1017
  }
510
1018
  ) : null,
511
- /* @__PURE__ */ jsx(
1019
+ /* @__PURE__ */ jsx3(
512
1020
  "button",
513
1021
  {
514
1022
  type: "button",
515
1023
  "aria-label": isFullscreen ? "Exit fullscreen" : "Enter fullscreen",
516
1024
  onClick: onToggleFullscreen,
517
1025
  style: buttonStyle,
518
- children: /* @__PURE__ */ jsx(PlayerIcon, { name: isFullscreen ? "exit-fullscreen" : "enter-fullscreen" })
1026
+ children: /* @__PURE__ */ jsx3(PlayerIcon, { name: isFullscreen ? "exit-fullscreen" : "enter-fullscreen" })
519
1027
  }
520
1028
  )
521
1029
  ]
@@ -527,7 +1035,7 @@ function PlayerControls({
527
1035
  }
528
1036
 
529
1037
  // src/player/use-playback-clock.ts
530
- import { useEffect } from "react";
1038
+ import { useEffect as useEffect4 } from "react";
531
1039
  function usePlaybackClock({
532
1040
  isPlaying,
533
1041
  stateRef,
@@ -540,12 +1048,14 @@ function usePlaybackClock({
540
1048
  setCurrentTime,
541
1049
  setIsPlaying
542
1050
  }) {
543
- useEffect(() => {
1051
+ useEffect4(() => {
544
1052
  if (!isPlaying) return;
545
1053
  let stalled = false;
1054
+ let stallReason;
546
1055
  let onsetWaitSeconds = 0;
547
1056
  let clockWaitSeconds = 0;
548
1057
  let lastNarrationTime;
1058
+ let completionHold;
549
1059
  let committedTime = timeRef.current;
550
1060
  let groupHandoff;
551
1061
  let requestId = stateRef.current.requestId;
@@ -557,11 +1067,18 @@ function usePlaybackClock({
557
1067
  } catch {
558
1068
  }
559
1069
  };
560
- const reportStall = (next) => {
561
- if (stalled === next) return;
1070
+ const reportStall = (next, reason) => {
1071
+ if (stalled === next && (!next || stallReason === reason)) return;
1072
+ if (stalled && next) {
1073
+ try {
1074
+ void Promise.resolve(callbacksRef.current.onStallChange?.(false, stallReason)).catch(() => void 0);
1075
+ } catch {
1076
+ }
1077
+ }
562
1078
  stalled = next;
1079
+ stallReason = reason;
563
1080
  try {
564
- void Promise.resolve(callbacksRef.current.onStallChange?.(next)).catch(() => void 0);
1081
+ void Promise.resolve(callbacksRef.current.onStallChange?.(next, reason)).catch(() => void 0);
565
1082
  } catch {
566
1083
  }
567
1084
  };
@@ -574,8 +1091,12 @@ function usePlaybackClock({
574
1091
  const replaced = current.requestId !== requestId || current.runId !== runId;
575
1092
  requestId = current.requestId;
576
1093
  runId = current.runId;
577
- if (externallySeeked || replaced) groupHandoff = void 0;
1094
+ if (externallySeeked || replaced) {
1095
+ groupHandoff = void 0;
1096
+ completionHold = void 0;
1097
+ }
578
1098
  let deferGroupStall = false;
1099
+ let completionBlocked = false;
579
1100
  const elapsed = Math.max(0, (now - previous) / 1e3);
580
1101
  let narrationReady = true;
581
1102
  try {
@@ -616,7 +1137,38 @@ function usePlaybackClock({
616
1137
  failNarration(new Error("Narration audio clock did not advance within eight seconds"), current);
617
1138
  return;
618
1139
  }
619
- const raw = narrationTime !== void 0 && cued ? cued.start - (cued.scene.narrationGroup?.offsetSeconds ?? 0) + narrationTime : timeRef.current + delta;
1140
+ let raw = narrationTime !== void 0 && cued ? cued.start - (cued.scene.narrationGroup?.offsetSeconds ?? 0) + narrationTime : timeRef.current + delta;
1141
+ if (narrationTime !== void 0 && !audioMovedBackwards && !externallySeeked && !replaced) {
1142
+ raw = Math.max(timeRef.current, raw);
1143
+ }
1144
+ if (cued && !cued.scene.narrationGroup && narrationTime === void 0) {
1145
+ let speaking = false;
1146
+ try {
1147
+ speaking = callbacksRef.current.narrationActive?.(cued.scene) === true;
1148
+ } catch (cause) {
1149
+ failNarration(cause instanceof Error ? cause : new Error("Narration completion failed"), current);
1150
+ return;
1151
+ }
1152
+ if (completionHold?.sceneId !== cued.scene.id) completionHold = void 0;
1153
+ if (raw >= cued.end - CLIP_NARRATION_TAIL_SEC && speaking) {
1154
+ completionHold ??= { sceneId: cued.scene.id, wait: 0, tail: 0 };
1155
+ if (raw >= cued.end) completionHold.wait += elapsed;
1156
+ if (completionHold.wait >= 8) {
1157
+ failNarration(new Error("Narration did not finish within eight seconds of its scene budget"), current);
1158
+ return;
1159
+ }
1160
+ if (raw >= cued.end) {
1161
+ raw = Math.max(cued.start, cued.end - 0.01);
1162
+ completionBlocked = true;
1163
+ }
1164
+ } else if (completionHold && !speaking) {
1165
+ completionHold.tail += elapsed;
1166
+ if (completionHold.tail < CLIP_NARRATION_TAIL_SEC && raw >= cued.end) {
1167
+ raw = Math.max(cued.start, cued.end - 0.01);
1168
+ completionBlocked = true;
1169
+ } else if (completionHold.tail >= CLIP_NARRATION_TAIL_SEC) completionHold = void 0;
1170
+ }
1171
+ }
620
1172
  let nextTime;
621
1173
  if (looping && duration2 > 0 && raw >= duration2) {
622
1174
  nextTime = raw % duration2;
@@ -667,7 +1219,8 @@ function usePlaybackClock({
667
1219
  }
668
1220
  const duration = current.config ? getVideoDuration(current.config) : 0;
669
1221
  const active = current.config ? resolveVideoTimeline(current.config).find((range) => timeRef.current >= range.start && timeRef.current < range.end) : void 0;
670
- reportStall(Boolean(active && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(active.scene) && !deferGroupStall) || !settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
1222
+ const reason = !narrationReady || completionBlocked ? "speech" : active && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(active.scene) && !deferGroupStall ? "media-decoding" : !settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration ? "scene-generation" : void 0;
1223
+ reportStall(Boolean(reason), reason);
671
1224
  committedTime = timeRef.current;
672
1225
  if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
673
1226
  else setIsPlaying(false);
@@ -681,7 +1234,7 @@ function usePlaybackClock({
681
1234
  }
682
1235
 
683
1236
  // src/player/video-player.tsx
684
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1237
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
685
1238
  var MINIMUM_GENERATION_INTRO_MS = 3e3;
686
1239
  function savedVideoState(video) {
687
1240
  return {
@@ -691,7 +1244,6 @@ function savedVideoState(video) {
691
1244
  };
692
1245
  }
693
1246
  function VideoPlayerRuntime({
694
- kit,
695
1247
  stream,
696
1248
  video,
697
1249
  playbackMode,
@@ -713,6 +1265,8 @@ function VideoPlayerRuntime({
713
1265
  onSceneChange,
714
1266
  narrationReady,
715
1267
  narrationTime,
1268
+ narrationActive,
1269
+ onPlaybackMetric,
716
1270
  onFramePresented,
717
1271
  onMediaFramePresented,
718
1272
  onStallChange,
@@ -722,10 +1276,10 @@ function VideoPlayerRuntime({
722
1276
  1,
723
1277
  typeof nativeMediaAudio?.volume === "number" && Number.isFinite(nativeMediaAudio.volume) ? nativeMediaAudio.volume : 1
724
1278
  ));
725
- const [reducedMotion, setReducedMotion] = useState(
1279
+ const [reducedMotion, setReducedMotion] = useState2(
726
1280
  () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
727
1281
  );
728
- const [audioUnlocked, setAudioUnlocked] = useState(false);
1282
+ const [audioUnlocked, setAudioUnlocked] = useState2(false);
729
1283
  const { resolvedStartMuted, shouldAutoPlay, autoStartGeneration } = resolvePlaybackPolicy({
730
1284
  playbackMode,
731
1285
  autoPlay,
@@ -734,28 +1288,40 @@ function VideoPlayerRuntime({
734
1288
  reducedMotion,
735
1289
  hasStream: Boolean(stream)
736
1290
  });
737
- const [isPlaying, setIsPlaying] = useState(() => shouldAutoPlay && !reducedMotion && !autoStartGeneration);
738
- const [isMuted, setIsMuted] = useState(resolvedStartMuted);
739
- const [fullscreenMode, setFullscreenMode] = useState("none");
740
- const [state, setState] = useState(() => video ? savedVideoState(video) : createVideoState());
741
- const [currentTime, setCurrentTime] = useState(0);
742
- const [activeStream, setActiveStream] = useState(stream);
743
- const [activeSavedVideo, setActiveSavedVideo] = useState(video);
744
- const [replacementPending, setReplacementPending] = useState(false);
745
- const [startRequested, setStartRequested] = useState(autoStartGeneration);
746
- const [introPlaying, setIntroPlaying] = useState(autoStartGeneration);
747
- const [generationIntroComplete, setGenerationIntroComplete] = useState(() => !playbackMode || !stream);
748
- const [observedWidth, setObservedWidth] = useState(0);
749
- const containerRef = useRef(null);
750
- const stateRef = useRef(state);
751
- const timeRef = useRef(currentTime);
752
- const audioRef = useRef(null);
753
- const introStartedAtRef = useRef(autoStartGeneration ? performance.now() : null);
754
- const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange });
755
- const loopRef = useRef(loop);
756
- const sceneIndexRef = useRef(-1);
757
- const mediaFrameReportedRef = useRef(false);
758
- const visualReadyRef = useRef(void 0);
1291
+ const [isPlaying, setIsPlaying] = useState2(() => shouldAutoPlay && !reducedMotion && !autoStartGeneration);
1292
+ const [isMuted, setIsMuted] = useState2(resolvedStartMuted);
1293
+ const [fullscreenMode, setFullscreenMode] = useState2("none");
1294
+ const [state, setState] = useState2(() => video ? savedVideoState(video) : createVideoState());
1295
+ const [currentTime, setCurrentTime] = useState2(0);
1296
+ const savedActiveIndex = video ? resolveVideoTimeline(video).findIndex((range) => currentTime >= range.start && currentTime < range.end) : -1;
1297
+ const savedPreparationIndex = savedActiveIndex >= 0 ? savedActiveIndex : Math.max(0, (video?.scenes.length ?? 1) - 1);
1298
+ useEffect5(() => {
1299
+ if (!video) return;
1300
+ const controller = new AbortController();
1301
+ for (const scene of video.scenes.slice(savedPreparationIndex, savedPreparationIndex + 2)) {
1302
+ preloadBuiltinTemplate(scene.templateId);
1303
+ warmSceneMedia(scene.variables, controller.signal);
1304
+ }
1305
+ return () => controller.abort();
1306
+ }, [video, savedPreparationIndex]);
1307
+ const [activeStream, setActiveStream] = useState2(stream);
1308
+ const [activeSavedVideo, setActiveSavedVideo] = useState2(video);
1309
+ const [replacementPending, setReplacementPending] = useState2(false);
1310
+ const [startRequested, setStartRequested] = useState2(autoStartGeneration);
1311
+ const [introPlaying, setIntroPlaying] = useState2(autoStartGeneration);
1312
+ const [generationIntroComplete, setGenerationIntroComplete] = useState2(() => !playbackMode || !stream);
1313
+ const [observedWidth, setObservedWidth] = useState2(0);
1314
+ const containerRef = useRef4(null);
1315
+ usePlaybackDiagnostics(containerRef, state.config ?? void 0, onPlaybackMetric);
1316
+ const stateRef = useRef4(state);
1317
+ const timeRef = useRef4(currentTime);
1318
+ const audioRef = useRef4(null);
1319
+ const introStartedAtRef = useRef4(autoStartGeneration ? performance.now() : null);
1320
+ const callbacksRef = useRef4({ onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, narrationActive, onFramePresented, onMediaFramePresented, onStallChange, onStateChange });
1321
+ const loopRef = useRef4(loop);
1322
+ const sceneIndexRef = useRef4(-1);
1323
+ const mediaFrameReportedRef = useRef4(false);
1324
+ const visualReadyRef = useRef4(void 0);
759
1325
  const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false) => {
760
1326
  if (error) {
761
1327
  setIsPlaying(false);
@@ -771,7 +1337,7 @@ function VideoPlayerRuntime({
771
1337
  }
772
1338
  }
773
1339
  }, []);
774
- const playbackEndedRef = useRef(false);
1340
+ const playbackEndedRef = useRef4(false);
775
1341
  let mediaPlaying = isPlaying;
776
1342
  if (sceneIndexRef.current >= 0) {
777
1343
  try {
@@ -782,7 +1348,7 @@ function VideoPlayerRuntime({
782
1348
  }
783
1349
  stateRef.current = state;
784
1350
  timeRef.current = currentTime;
785
- callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange };
1351
+ callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, narrationActive, onFramePresented, onMediaFramePresented, onStallChange, onStateChange };
786
1352
  loopRef.current = loop;
787
1353
  const reportFramePresented = useMemo(() => {
788
1354
  let reported = false;
@@ -827,7 +1393,7 @@ function VideoPlayerRuntime({
827
1393
  setGenerationIntroComplete(!playbackMode || !stream);
828
1394
  introStartedAtRef.current = autoStartReplacement ? performance.now() : null;
829
1395
  }
830
- useEffect2(() => {
1396
+ useEffect5(() => {
831
1397
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
832
1398
  const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
833
1399
  const handleChange = (event) => {
@@ -842,12 +1408,12 @@ function VideoPlayerRuntime({
842
1408
  preference.addEventListener?.("change", handleChange);
843
1409
  return () => preference.removeEventListener?.("change", handleChange);
844
1410
  }, []);
845
- useEffect2(() => {
1411
+ useEffect5(() => {
846
1412
  import("./control-visibility-NQOWTKOQ.js");
847
1413
  }, []);
848
- useEffect2(() => setIsMuted(resolvedStartMuted), [resolvedStartMuted]);
849
- const pausedRef = useRef(paused);
850
- useEffect2(() => {
1414
+ useEffect5(() => setIsMuted(resolvedStartMuted), [resolvedStartMuted]);
1415
+ const pausedRef = useRef4(paused);
1416
+ useEffect5(() => {
851
1417
  const previous = pausedRef.current;
852
1418
  pausedRef.current = paused;
853
1419
  if (paused === void 0 || paused === previous) return;
@@ -863,7 +1429,7 @@ function VideoPlayerRuntime({
863
1429
  const audio = audioRef.current;
864
1430
  if (audio?.paused) void audio.play().catch(Boolean);
865
1431
  }, [paused]);
866
- useEffect2(() => {
1432
+ useEffect5(() => {
867
1433
  let cancelled = false;
868
1434
  let started = false;
869
1435
  const reset = video ? savedVideoState(video) : createVideoState();
@@ -871,13 +1437,7 @@ function VideoPlayerRuntime({
871
1437
  timeRef.current = 0;
872
1438
  setState(reset);
873
1439
  setCurrentTime(0);
874
- if (video) {
875
- for (const scene of video.scenes) {
876
- preloadBuiltinTemplate(scene.templateId);
877
- warmSceneMedia(scene.variables);
878
- }
879
- return;
880
- }
1440
+ if (video) return;
881
1441
  if (!stream) return;
882
1442
  const consume = async () => {
883
1443
  try {
@@ -913,7 +1473,7 @@ function VideoPlayerRuntime({
913
1473
  }
914
1474
  };
915
1475
  }, [stream, video]);
916
- useEffect2(() => {
1476
+ useEffect5(() => {
917
1477
  if (width != null) return;
918
1478
  const container = containerRef.current;
919
1479
  if (!container) return;
@@ -938,12 +1498,12 @@ function VideoPlayerRuntime({
938
1498
  setCurrentTime,
939
1499
  setIsPlaying
940
1500
  });
941
- useEffect2(() => {
1501
+ useEffect5(() => {
942
1502
  const audio = audioRef.current;
943
1503
  if (!audio) return;
944
1504
  audio.volume = state.config?.audio?.volume ?? 1;
945
1505
  }, [state.config?.audio?.audioUrl, state.config?.audio?.volume]);
946
- useEffect2(() => {
1506
+ useEffect5(() => {
947
1507
  const audio = audioRef.current;
948
1508
  if (!audio) return;
949
1509
  if (isPlaying || introPlaying) {
@@ -961,7 +1521,7 @@ function VideoPlayerRuntime({
961
1521
  audio.pause();
962
1522
  }
963
1523
  }, [introPlaying, isPlaying, state.config?.audio?.audioUrl]);
964
- useEffect2(() => {
1524
+ useEffect5(() => {
965
1525
  if (!state.config?.scenes.length) {
966
1526
  const terminalWithoutVideo = state.status === "complete" || state.status === "error" || state.status === "aborted";
967
1527
  if (terminalWithoutVideo) {
@@ -1019,7 +1579,7 @@ function VideoPlayerRuntime({
1019
1579
  const terminal = state.status === "complete" || state.status === "error" || state.status === "aborted";
1020
1580
  const playheadAtEnd = terminal && duration > 0 && currentTime >= duration - 1e-3;
1021
1581
  const ended = !loop && playheadAtEnd;
1022
- useEffect2(() => {
1582
+ useEffect5(() => {
1023
1583
  if (!playheadAtEnd) {
1024
1584
  playbackEndedRef.current = false;
1025
1585
  return;
@@ -1032,7 +1592,7 @@ function VideoPlayerRuntime({
1032
1592
  const firstSceneRange = config ? resolveVideoTimeline(config)[0] : void 0;
1033
1593
  const hasSuppliedOpening = firstSceneRange?.scene.id === "supplied-opening";
1034
1594
  const showStartPoster = (!generationIntroWaiting || hasSuppliedOpening) && !startRequested && !isPlaying && !ended && currentTime <= 1e-3 && Boolean(config?.scenes.length);
1035
- const firstSceneHoldProgress = firstSceneRange ? kit.getTemplate(firstSceneRange.scene.templateId)?.transitionTiming?.holdProgress ?? 0.7 : 0;
1595
+ const firstSceneHoldProgress = firstSceneRange ? 0.7 : 0;
1036
1596
  const posterTime = firstSceneRange ? firstSceneRange.start + Math.max(0, firstSceneRange.end - firstSceneRange.start) * firstSceneHoldProgress : currentTime;
1037
1597
  const startPlayback = () => {
1038
1598
  setGenerationIntroComplete(true);
@@ -1095,7 +1655,7 @@ function VideoPlayerRuntime({
1095
1655
  const pendingReplacement = replacementPending && state.status === "idle";
1096
1656
  const displayedStatus = pendingReplacement ? "streaming" : state.status;
1097
1657
  const generationCoverVisible = generationIntroWaiting && !hasSuppliedOpening || displayedStatus === "streaming" && !config?.scenes.length;
1098
- return /* @__PURE__ */ jsxs2(
1658
+ return /* @__PURE__ */ jsxs3(
1099
1659
  "div",
1100
1660
  {
1101
1661
  ref: containerRef,
@@ -1135,7 +1695,7 @@ function VideoPlayerRuntime({
1135
1695
  ...style
1136
1696
  },
1137
1697
  children: [
1138
- /* @__PURE__ */ jsx2(
1698
+ /* @__PURE__ */ jsx4(
1139
1699
  GenerationCover,
1140
1700
  {
1141
1701
  visible: generationCoverVisible,
@@ -1146,10 +1706,9 @@ function VideoPlayerRuntime({
1146
1706
  onStart: armPlayback
1147
1707
  }
1148
1708
  ),
1149
- !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx2(MountedReadinessContext.Provider, { value: reportVisualReady, children: /* @__PURE__ */ jsx2(
1709
+ !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx4(MountedReadinessContext.Provider, { value: reportVisualReady, children: /* @__PURE__ */ jsx4(
1150
1710
  VideoFrame,
1151
1711
  {
1152
- kit,
1153
1712
  onFramePresented: !showStartPoster && onFramePresented ? reportFramePresented : void 0,
1154
1713
  config: displayConfig,
1155
1714
  time: showStartPoster ? posterTime : currentTime,
@@ -1168,7 +1727,7 @@ function VideoPlayerRuntime({
1168
1727
  }
1169
1728
  }
1170
1729
  ) }) : null,
1171
- /* @__PURE__ */ jsx2(
1730
+ /* @__PURE__ */ jsx4(
1172
1731
  StartPosterButton,
1173
1732
  {
1174
1733
  visible: showStartPoster,
@@ -1177,7 +1736,7 @@ function VideoPlayerRuntime({
1177
1736
  onStart: startPlayback
1178
1737
  }
1179
1738
  ),
1180
- config?.audio ? /* @__PURE__ */ jsx2(
1739
+ config?.audio ? /* @__PURE__ */ jsx4(
1181
1740
  "audio",
1182
1741
  {
1183
1742
  ref: audioRef,
@@ -1190,8 +1749,8 @@ function VideoPlayerRuntime({
1190
1749
  },
1191
1750
  config.audio.audioUrl
1192
1751
  ) : null,
1193
- /* @__PURE__ */ jsx2(EndedOverlay, { ended: controls && ended, onReplay: togglePlayback }),
1194
- /* @__PURE__ */ jsx2(
1752
+ /* @__PURE__ */ jsx4(EndedOverlay, { ended: controls && ended, onReplay: togglePlayback }),
1753
+ /* @__PURE__ */ jsx4(
1195
1754
  PlayerControls,
1196
1755
  {
1197
1756
  visible: controls && !generationCoverVisible && !showStartPoster && Boolean(config?.scenes.length),
@@ -1214,7 +1773,6 @@ function VideoPlayerRuntime({
1214
1773
  );
1215
1774
  }
1216
1775
  function VideoPlayer({
1217
- templates,
1218
1776
  stream,
1219
1777
  video,
1220
1778
  onPlaybackEnd,
@@ -1223,16 +1781,11 @@ function VideoPlayer({
1223
1781
  ...props
1224
1782
  }) {
1225
1783
  const savedVideo = useMemo(() => video ? parseVideo(video) : void 0, [video]);
1226
- const kit = useMemo(
1227
- () => overlayPlayerTemplateRegistry(BUILTIN_PLAYER_KIT, templates),
1228
- [templates]
1229
- );
1230
1784
  if (!stream && !savedVideo) return null;
1231
- return /* @__PURE__ */ jsx2(
1785
+ return /* @__PURE__ */ jsx4(
1232
1786
  VideoPlayerRuntime,
1233
1787
  {
1234
1788
  ...props,
1235
- kit,
1236
1789
  stream,
1237
1790
  video: savedVideo,
1238
1791
  onPlaybackEnd: (state) => {
@@ -1335,21 +1888,145 @@ function createCaptionVoice(source, now = () => performance.now()) {
1335
1888
  return { voice, getCaptionProgress: read, reset };
1336
1889
  }
1337
1890
 
1891
+ // src/player/prepare-scene-media.ts
1892
+ async function prepareSceneMedia(variables, signal) {
1893
+ signal.throwIfAborted();
1894
+ const url = typeof variables.mediaUrl === "string" ? variables.mediaUrl.trim() : "";
1895
+ if (!url || variables.mediaType === "gradient") return "graphic";
1896
+ const video = variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(url);
1897
+ if (video) return "awaiting-video-frame";
1898
+ await new Promise((resolve, reject) => {
1899
+ const image = new Image();
1900
+ let settled = false;
1901
+ const finish = (error) => {
1902
+ if (settled) return;
1903
+ settled = true;
1904
+ clearTimeout(timer);
1905
+ signal.removeEventListener("abort", abort);
1906
+ image.onload = null;
1907
+ image.onerror = null;
1908
+ if (error) {
1909
+ image.src = "";
1910
+ reject(error);
1911
+ } else resolve();
1912
+ };
1913
+ const abort = () => finish(signal.reason ?? new DOMException("Cancelled", "AbortError"));
1914
+ const timer = setTimeout(() => finish(new DOMException("Media preparation timed out", "TimeoutError")), 8e3);
1915
+ signal.addEventListener("abort", abort, { once: true });
1916
+ image.onload = () => {
1917
+ void (image.decode ? image.decode() : Promise.resolve()).then(() => finish(), () => finish(new Error("Could not prepare scene image")));
1918
+ };
1919
+ image.onerror = () => finish(new Error("Could not prepare scene image"));
1920
+ image.src = url;
1921
+ if (image.complete && image.naturalWidth > 0) image.onload(new Event("load"));
1922
+ });
1923
+ return "image";
1924
+ }
1925
+
1338
1926
  // src/player/recover-scene-media.ts
1339
1927
  function recoverSceneMedia(scene) {
1340
1928
  if (scene.templateId === "cinemaMedia") {
1341
1929
  const title = scene.variables.fallbackText;
1342
- if (typeof title !== "string" || !title.trim() || [...title].length > 65) return { ...scene, variables: { mediaType: "video", mediaUrl: "" } };
1343
- return { ...scene, templateId: "chapterTitle", variables: { title: title.trim() } };
1930
+ const fallback = typeof title === "string" && title.trim() && [...title].length <= 65 ? title.trim() : "Your response continues.";
1931
+ return { ...scene, templateId: "chapterTitle", variables: { title: fallback } };
1344
1932
  }
1345
- if (!["comparison", "editorialTimeline", "quote", "keyFigure"].includes(scene.templateId)) return void 0;
1346
- const variables = { ...scene.variables };
1347
- for (const key of ["mediaUrl", "mediaPoster", "mediaType", "mediaKeyword", "mediaSource"]) delete variables[key];
1348
- return { ...scene, variables };
1933
+ return void 0;
1934
+ }
1935
+
1936
+ // src/player/scene-readiness.ts
1937
+ function preparedSceneDuration(scene, spokenSeconds, metadata, clipDurationSec) {
1938
+ const timing = metadata?.timing;
1939
+ const authored = (timing?.revealSeconds ?? 0) + (timing?.holdSeconds ?? 0) + (timing?.exitSeconds ?? 0);
1940
+ const readable = Math.max(getSceneDurationBounds(scene, metadata).readable, authored);
1941
+ const floor = scene.templateId === "cinemaMedia" && clipDurationSec !== void 0 && Number.isFinite(clipDurationSec) && clipDurationSec > 0 ? Math.min(readable, clipDurationSec) : readable;
1942
+ const speech = Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? spokenSeconds + CLIP_NARRATION_TAIL_SEC : scene.narration ? getSpokenDuration(scene.narration) : 0;
1943
+ return Math.max(floor, speech);
1944
+ }
1945
+ function prepareNarratedScene(scene, spokenSeconds) {
1946
+ const requested = scene.timing.fixedDuration;
1947
+ const actual = scene.variables.mediaDurationSec;
1948
+ const clipDurationSec = scene.templateId === "cinemaMedia" ? [actual, requested].find((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : void 0;
1949
+ const recovered = clipDurationSec !== void 0 && spokenSeconds !== void 0 && !speechFitsClip(spokenSeconds, clipDurationSec);
1950
+ const visual = recovered ? recoverSceneMedia(scene) : scene;
1951
+ const duration = preparedSceneDuration(visual, spokenSeconds, getBuiltinSceneDefinition(visual.templateId), clipDurationSec);
1952
+ const { startTime: _start, endTime: _end, beatStart: _beatStart, beatEnd: _beatEnd, ...timing } = visual.timing;
1953
+ return { scene: { ...visual, timing: { ...timing, fixedDuration: duration } }, recovered, clipDurationSec };
1954
+ }
1955
+
1956
+ // src/video-chat/scene-preparation.ts
1957
+ function createScenePreparation(options) {
1958
+ const speechLoads = /* @__PURE__ */ new Map();
1959
+ const lanes = [Promise.resolve(), Promise.resolve()];
1960
+ const announced = /* @__PURE__ */ new Set();
1961
+ const announcedMedia = /* @__PURE__ */ new Set();
1962
+ let nextLane = 0;
1963
+ let customVoiceFailed = false;
1964
+ const prepareSpeech = (text, signal = options.signal) => {
1965
+ const cached = speechLoads.get(text);
1966
+ if (cached) return cached;
1967
+ const lane = nextLane++ % lanes.length;
1968
+ const prepared = lanes[lane].then(async () => {
1969
+ signal.throwIfAborted();
1970
+ try {
1971
+ if (customVoiceFailed) throw new Error("Voice preparation did not respond");
1972
+ return options.customVoice ? await withDeadline((child) => options.voice().prepare(text, { signal: child }), 3e3, signal) : await options.voice().prepare(text, { signal });
1973
+ } catch (cause) {
1974
+ if (options.customVoice && !signal.aborted && !options.signal.aborted) {
1975
+ options.onVoiceUnavailable(text);
1976
+ if (cause instanceof DOMException && cause.name === "TimeoutError") customVoiceFailed = true;
1977
+ }
1978
+ throw cause;
1979
+ }
1980
+ });
1981
+ speechLoads.set(text, prepared);
1982
+ lanes[lane] = prepared.catch(() => void 0);
1983
+ return prepared;
1984
+ };
1985
+ const announce = (value) => {
1986
+ if (options.signal.aborted || !value || typeof value !== "object" || Array.isArray(value)) return;
1987
+ const data = value;
1988
+ if (typeof data.sceneId !== "string" || !data.sceneId || data.sceneId.length > 200 || typeof data.narration !== "string" || !data.narration.trim() || data.narration.length > 2e3 || !announced.has(data.sceneId) && announced.size >= 32) return;
1989
+ announced.add(data.sceneId);
1990
+ if (speechLoads.size < 32 || speechLoads.has(data.narration.trim())) void prepareSpeech(data.narration.trim()).catch(() => void 0);
1991
+ const media = sanitizeVideoChatMedia(data.media);
1992
+ if (media && !announcedMedia.has(data.sceneId)) {
1993
+ announcedMedia.add(data.sceneId);
1994
+ warmSceneMedia({ mediaUrl: media.url, mediaType: media.type === "image" ? "photo" : "video", mediaPoster: media.posterUrl }, options.signal);
1995
+ }
1996
+ };
1997
+ const prepareVisual = (scene) => {
1998
+ warmSceneMedia(scene.variables, options.signal);
1999
+ const prepared = Promise.resolve(preloadBuiltinTemplate(scene.templateId)).then(async () => {
2000
+ try {
2001
+ await prepareSceneMedia(scene.variables, options.signal);
2002
+ return scene;
2003
+ } catch (cause) {
2004
+ if (options.signal.aborted) throw cause;
2005
+ const recovered = recoverSceneMedia(scene);
2006
+ if (!recovered) throw new VideoError("Scene could not prepare its visual", { code: "media_not_ready" });
2007
+ options.warn(MEDIA_RECOVERY_NOTICE);
2008
+ return recovered;
2009
+ }
2010
+ });
2011
+ void prepared.catch(() => void 0);
2012
+ return prepared;
2013
+ };
2014
+ const pace = (scene, seconds) => {
2015
+ const prepared = prepareNarratedScene(scene, seconds);
2016
+ if (prepared.recovered) options.warn(MEDIA_RECOVERY_NOTICE);
2017
+ if (seconds !== void 0 && prepared.clipDurationSec !== void 0) {
2018
+ try {
2019
+ options.onDuration?.({ speechDurationSec: seconds, clipDurationSec: prepared.clipDurationSec, recovered: prepared.recovered });
2020
+ } catch {
2021
+ }
2022
+ }
2023
+ return prepared.scene;
2024
+ };
2025
+ return { prepareSpeech, announce, prepareVisual, pace };
1349
2026
  }
1350
2027
 
1351
2028
  // src/video-chat/use-video-chat.ts
1352
- import { useCallback as useCallback2, useEffect as useEffect4, useMemo as useMemo2, useReducer, useRef as useRef3 } from "react";
2029
+ import { useCallback as useCallback3, useEffect as useEffect7, useMemo as useMemo2, useReducer, useRef as useRef6 } from "react";
1353
2030
 
1354
2031
  // src/protocol/scene-timeline.ts
1355
2032
  function createQueue() {
@@ -1457,25 +2134,17 @@ function createSceneTimeline(options) {
1457
2134
  };
1458
2135
  }
1459
2136
 
1460
- // src/player/scene-readiness.ts
1461
- function preparedSceneDuration(scene, spokenSeconds, metadata) {
1462
- const timing = metadata?.timing;
1463
- const authored = (timing?.revealSeconds ?? 0) + (timing?.holdSeconds ?? 0) + (timing?.exitSeconds ?? 0);
1464
- const readable = Math.max(getSceneDurationBounds(scene, metadata).readable, authored);
1465
- return Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? Math.max(readable, spokenSeconds + 0.8) : Math.max(readable, getSceneDuration(scene, metadata));
1466
- }
1467
-
1468
2137
  // src/player/use-narration.ts
1469
- import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
2138
+ import { useCallback as useCallback2, useEffect as useEffect6, useRef as useRef5, useState as useState3 } from "react";
1470
2139
  function useNarration(options) {
1471
- const [speaking, setSpeaking] = useState2(false);
1472
- const optionsRef = useRef2(options);
2140
+ const [speaking, setSpeaking] = useState3(false);
2141
+ const optionsRef = useRef5(options);
1473
2142
  optionsRef.current = options;
1474
- const groupRef = useRef2(void 0);
1475
- const clockRef = useRef2(void 0);
1476
- const clockSceneRef = useRef2(void 0);
1477
- const readyRef = useRef2(true);
1478
- const getTime = useCallback((scene) => {
2143
+ const groupRef = useRef5(void 0);
2144
+ const clockRef = useRef5(void 0);
2145
+ const clockSceneRef = useRef5(void 0);
2146
+ const readyRef = useRef5(true);
2147
+ const getTime = useCallback2((scene) => {
1479
2148
  const group = groupRef.current;
1480
2149
  if (optionsRef.current.enabled === false || (group ? scene.narrationGroup?.id !== group.id : scene.id !== clockSceneRef.current)) return void 0;
1481
2150
  if (!optionsRef.current.voice.getCurrentTime) return void 0;
@@ -1488,10 +2157,11 @@ function useNarration(options) {
1488
2157
  }
1489
2158
  return clockRef.current;
1490
2159
  }, []);
1491
- const isReady = useCallback(() => optionsRef.current.enabled === false || readyRef.current || clockRef.current !== void 0 && clockRef.current >= (groupRef.current?.offsetSeconds ?? 0) + 0.04, []);
1492
- const currentRef = useRef2(void 0);
1493
- const spokenIndexRef = useRef2(void 0);
1494
- const stop = useCallback(() => {
2160
+ const isReady = useCallback2(() => optionsRef.current.enabled === false || readyRef.current || clockRef.current !== void 0 && clockRef.current >= (groupRef.current?.offsetSeconds ?? 0) + 0.04, []);
2161
+ const currentRef = useRef5(void 0);
2162
+ const isSpeaking = useCallback2((scene) => optionsRef.current.enabled !== false && Boolean(currentRef.current) && (groupRef.current ? scene.narrationGroup?.id === groupRef.current.id : scene.id === clockSceneRef.current), []);
2163
+ const spokenIndexRef = useRef5(void 0);
2164
+ const stop = useCallback2(() => {
1495
2165
  readyRef.current = true;
1496
2166
  currentRef.current?.abort();
1497
2167
  currentRef.current = void 0;
@@ -1500,12 +2170,12 @@ function useNarration(options) {
1500
2170
  clockSceneRef.current = void 0;
1501
2171
  setSpeaking(false);
1502
2172
  }, []);
1503
- const interrupt = useCallback(() => {
2173
+ const interrupt = useCallback2(() => {
1504
2174
  spokenIndexRef.current = void 0;
1505
2175
  stop();
1506
2176
  }, [stop]);
1507
- useEffect3(() => () => currentRef.current?.abort(), []);
1508
- const onSceneChange = useCallback((scene, index) => {
2177
+ useEffect6(() => () => currentRef.current?.abort(), []);
2178
+ const onSceneChange = useCallback2((scene, index) => {
1509
2179
  const { voice, enabled = true } = optionsRef.current;
1510
2180
  if (!enabled) return;
1511
2181
  if (spokenIndexRef.current === index) return;
@@ -1565,42 +2235,7 @@ function useNarration(options) {
1565
2235
  }
1566
2236
  })();
1567
2237
  }, [stop]);
1568
- return { onSceneChange, interrupt, speaking, isReady, getTime };
1569
- }
1570
-
1571
- // src/player/prepare-scene-media.ts
1572
- async function prepareSceneMedia(variables, signal) {
1573
- signal.throwIfAborted();
1574
- const url = typeof variables.mediaUrl === "string" ? variables.mediaUrl.trim() : "";
1575
- if (!url || variables.mediaType === "gradient") return "graphic";
1576
- const video = variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(url);
1577
- if (video) return "awaiting-video-frame";
1578
- await new Promise((resolve, reject) => {
1579
- const image = new Image();
1580
- let settled = false;
1581
- const finish = (error) => {
1582
- if (settled) return;
1583
- settled = true;
1584
- clearTimeout(timer);
1585
- signal.removeEventListener("abort", abort);
1586
- image.onload = null;
1587
- image.onerror = null;
1588
- if (error) {
1589
- image.src = "";
1590
- reject(error);
1591
- } else resolve();
1592
- };
1593
- const abort = () => finish(signal.reason ?? new DOMException("Cancelled", "AbortError"));
1594
- const timer = setTimeout(() => finish(new DOMException("Media preparation timed out", "TimeoutError")), 8e3);
1595
- signal.addEventListener("abort", abort, { once: true });
1596
- image.onload = () => {
1597
- void (image.decode ? image.decode() : Promise.resolve()).then(() => finish(), () => finish(new Error("Could not prepare scene image")));
1598
- };
1599
- image.onerror = () => finish(new Error("Could not prepare scene image"));
1600
- image.src = url;
1601
- if (image.complete && image.naturalWidth > 0) image.onload(new Event("load"));
1602
- });
1603
- return "image";
2238
+ return { onSceneChange, interrupt, speaking, isReady, getTime, isSpeaking };
1604
2239
  }
1605
2240
 
1606
2241
  // src/video-chat/voice.ts
@@ -1614,8 +2249,7 @@ function actionEndpoint(endpoint, action) {
1614
2249
  return `${value}${value.includes("?") ? "&" : "?"}action=${encodeURIComponent(action)}`;
1615
2250
  }
1616
2251
  function estimatedBrowserSeconds(text) {
1617
- const words = text.trim().split(/\s+/u).filter(Boolean).length;
1618
- return Math.max(1, words / 2.5);
2252
+ return Math.max(1, estimateNarrationSeconds(text));
1619
2253
  }
1620
2254
  async function measureSeconds(bytes) {
1621
2255
  try {
@@ -1950,7 +2584,7 @@ function createVideoChatVoice(options = {}) {
1950
2584
  }
1951
2585
 
1952
2586
  // src/video-chat/use-video-chat.ts
1953
- var DEFAULT_TIMEOUT_MS = 18e4;
2587
+ var DEFAULT_TIMEOUT_MS = 66e4;
1954
2588
  function observe(callback) {
1955
2589
  try {
1956
2590
  void Promise.resolve(callback?.()).catch(() => void 0);
@@ -2185,29 +2819,14 @@ async function responseError(response) {
2185
2819
  recoverable: false
2186
2820
  });
2187
2821
  }
2188
- function pacedScene(scene, spokenSeconds, templates) {
2189
- const held = preparedSceneDuration(
2190
- scene,
2191
- spokenSeconds,
2192
- templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId)
2193
- );
2194
- const {
2195
- startTime: _startTime,
2196
- endTime: _endTime,
2197
- beatStart: _beatStart,
2198
- beatEnd: _beatEnd,
2199
- ...timing
2200
- } = scene.timing ?? {};
2201
- return { ...scene, timing: { ...timing, fixedDuration: held } };
2202
- }
2203
2822
  function useVideoChat(options = {}) {
2204
2823
  return useVideoChatSession(options).chat;
2205
2824
  }
2206
2825
  function useVideoChatSession(options = {}) {
2207
- const optionsRef = useRef3(options);
2826
+ const optionsRef = useRef6(options);
2208
2827
  optionsRef.current = options;
2209
- const voiceWarningRef = useRef3(() => void 0);
2210
- const ownedVoiceRef = useRef3(void 0);
2828
+ const voiceWarningRef = useRef6(() => void 0);
2829
+ const ownedVoiceRef = useRef6(void 0);
2211
2830
  if (!options.voice && !ownedVoiceRef.current) {
2212
2831
  ownedVoiceRef.current = createVideoChatVoice({
2213
2832
  endpoint: options.endpoint,
@@ -2219,12 +2838,12 @@ function useVideoChatSession(options = {}) {
2219
2838
  }
2220
2839
  const rawVoice = options.voice ?? ownedVoiceRef.current;
2221
2840
  const captionVoice = useMemo2(() => createCaptionVoice(rawVoice), [rawVoice]);
2222
- useEffect4(() => () => captionVoice.reset(), [captionVoice]);
2841
+ useEffect7(() => () => captionVoice.reset(), [captionVoice]);
2223
2842
  const voice = captionVoice.voice;
2224
- const voiceRef = useRef3(voice);
2843
+ const voiceRef = useRef6(voice);
2225
2844
  voiceRef.current = voice;
2226
- const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
2227
- const speechStartRef = useRef3(() => void 0);
2845
+ const unavailableVoiceLines = useRef6(/* @__PURE__ */ new Set());
2846
+ const speechStartRef = useRef6(() => void 0);
2228
2847
  const narration = useNarration({ onSpeechStart: (source) => speechStartRef.current(source), voice: {
2229
2848
  supportsOffsets: voice.supportsOffsets,
2230
2849
  ...voice.getCurrentTime ? { getCurrentTime: () => voice.getCurrentTime() } : {},
@@ -2238,24 +2857,24 @@ function useVideoChatSession(options = {}) {
2238
2857
  }
2239
2858
  }
2240
2859
  } });
2241
- const narrationRef = useRef3(narration);
2860
+ const narrationRef = useRef6(narration);
2242
2861
  narrationRef.current = narration;
2243
2862
  const [state, dispatch] = useReducer(reducer, options.initialMuted ?? false, initialState);
2244
- const stateRef = useRef3(state);
2863
+ const stateRef = useRef6(state);
2245
2864
  stateRef.current = state;
2246
- const runRef = useRef3(0);
2247
- const inFlightRef = useRef3(void 0);
2248
- const suggestionsRef = useRef3(void 0);
2249
- const openingRef = useRef3(void 0);
2250
- const timelineRef = useRef3(void 0);
2251
- const heldRef = useRef3(false);
2252
- const flushRef = useRef3(void 0);
2253
- const mountedRef = useRef3(true);
2254
- const firstFrameRef = useRef3(void 0);
2255
- const reportMetric = useCallback2((metric) => {
2865
+ const runRef = useRef6(0);
2866
+ const inFlightRef = useRef6(void 0);
2867
+ const suggestionsRef = useRef6(void 0);
2868
+ const openingRef = useRef6(void 0);
2869
+ const timelineRef = useRef6(void 0);
2870
+ const heldRef = useRef6(false);
2871
+ const flushRef = useRef6(void 0);
2872
+ const mountedRef = useRef6(true);
2873
+ const firstFrameRef = useRef6(void 0);
2874
+ const reportMetric = useCallback3((metric) => {
2256
2875
  observe(() => optionsRef.current.onPlaybackMetric?.(metric));
2257
2876
  }, []);
2258
- const finishStall = useCallback2(() => {
2877
+ const finishStall = useCallback3(() => {
2259
2878
  const timing = firstFrameRef.current;
2260
2879
  if (!timing?.active || timing.stallStartedAt == null) return;
2261
2880
  const now = monotonicNow();
@@ -2267,10 +2886,10 @@ function useVideoChatSession(options = {}) {
2267
2886
  mode: timing.mode,
2268
2887
  elapsedMs: Math.max(0, Math.round(now - timing.startedAt)),
2269
2888
  durationMs: Math.max(0, Math.round(now - started)),
2270
- reason: "scene-generation"
2889
+ reason: timing.stallReason ?? "scene-generation"
2271
2890
  });
2272
2891
  }, [reportMetric]);
2273
- const endTiming = useCallback2(() => {
2892
+ const endTiming = useCallback3(() => {
2274
2893
  finishStall();
2275
2894
  if (firstFrameRef.current) firstFrameRef.current.active = false;
2276
2895
  }, [finishStall]);
@@ -2286,7 +2905,7 @@ function useVideoChatSession(options = {}) {
2286
2905
  source: optionsRef.current.voice ? "custom" : source === "generated" ? "generated" : "browser"
2287
2906
  });
2288
2907
  };
2289
- const request = useCallback2(async (action, init = {}, signal) => {
2908
+ const request = useCallback3(async (action, init = {}, signal) => {
2290
2909
  const current = optionsRef.current;
2291
2910
  const headers = new Headers(current.headers);
2292
2911
  new Headers(init.headers).forEach((header, name) => headers.set(name, header));
@@ -2300,7 +2919,7 @@ function useVideoChatSession(options = {}) {
2300
2919
  signal
2301
2920
  });
2302
2921
  }, []);
2303
- useEffect4(() => {
2922
+ useEffect7(() => {
2304
2923
  mountedRef.current = true;
2305
2924
  const controller = new AbortController();
2306
2925
  void request("capabilities", {}, controller.signal).then(async (response) => {
@@ -2331,8 +2950,8 @@ function useVideoChatSession(options = {}) {
2331
2950
  });
2332
2951
  };
2333
2952
  }, [request, endTiming]);
2334
- useEffect4(() => voice.setMuted(state.muted), [state.muted, voice]);
2335
- const cancel = useCallback2((reason = "Video chat was cancelled") => {
2953
+ useEffect7(() => voice.setMuted(state.muted), [state.muted, voice]);
2954
+ const cancel = useCallback3((reason = "Video chat was cancelled") => {
2336
2955
  endTiming();
2337
2956
  runRef.current += 1;
2338
2957
  inFlightRef.current?.abort(new DOMException(reason, "AbortError"));
@@ -2346,7 +2965,7 @@ function useVideoChatSession(options = {}) {
2346
2965
  narrationRef.current.interrupt();
2347
2966
  dispatch({ type: "cancelled" });
2348
2967
  }, [endTiming]);
2349
- const ask = useCallback2(async (value, askOptions = {}) => {
2968
+ const ask = useCallback3(async (value, askOptions = {}) => {
2350
2969
  const prompt = value.trim();
2351
2970
  if (!prompt) return void 0;
2352
2971
  endTiming();
@@ -2456,31 +3075,26 @@ function useVideoChatSession(options = {}) {
2456
3075
  }
2457
3076
  };
2458
3077
  flushRef.current = flush;
2459
- const speechLoads = /* @__PURE__ */ new Map();
2460
- const speechLanes = [Promise.resolve(), Promise.resolve()];
2461
- let nextSpeechLane = 0;
2462
- let customVoiceFailed = false;
2463
- const prepareSpeech = (text, signal) => {
2464
- const cached = speechLoads.get(text);
2465
- if (cached) return cached;
2466
- const lane = nextSpeechLane++ % speechLanes.length;
2467
- const prepared = speechLanes[lane].then(async () => {
2468
- signal.throwIfAborted();
2469
- try {
2470
- if (customVoiceFailed) throw new Error("Voice preparation did not respond");
2471
- return currentOptions.voice ? await withDeadline((child) => voiceRef.current.prepare(text, { signal: child }), 3e3, signal) : await voiceRef.current.prepare(text, { signal });
2472
- } catch (cause) {
2473
- if (currentOptions.voice && !signal.aborted && isCurrent()) {
2474
- unavailableVoiceLines.current.add(text);
2475
- if (cause instanceof DOMException && cause.name === "TimeoutError") customVoiceFailed = true;
2476
- }
2477
- throw cause;
2478
- }
2479
- });
2480
- speechLoads.set(text, prepared);
2481
- speechLanes[lane] = prepared.catch(() => void 0);
2482
- return prepared;
2483
- };
3078
+ const preparation = createScenePreparation({
3079
+ voice: () => voiceRef.current,
3080
+ customVoice: Boolean(currentOptions.voice),
3081
+ signal: controller.signal,
3082
+ onVoiceUnavailable: (text) => {
3083
+ if (isCurrent()) unavailableVoiceLines.current.add(text);
3084
+ },
3085
+ warn,
3086
+ onDuration: (value2) => {
3087
+ const timing = firstFrameRef.current;
3088
+ if (isCurrent() && timing?.active) reportMetric({
3089
+ type: "scene-duration",
3090
+ ...value2,
3091
+ turnId: id,
3092
+ mode,
3093
+ elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt))
3094
+ });
3095
+ }
3096
+ });
3097
+ const { prepareSpeech } = preparation;
2484
3098
  const speakOpening = async (text) => {
2485
3099
  try {
2486
3100
  if (!isOpeningCurrent()) return;
@@ -2539,10 +3153,7 @@ function useVideoChatSession(options = {}) {
2539
3153
  warn(event.type === "response.warning" && event.data.warning.message === MEDIA_RECOVERY_NOTICE ? MEDIA_RECOVERY_NOTICE : "Some parts were simplified so the response could continue.");
2540
3154
  }
2541
3155
  if (event.type === "data.video-chat-preparation") {
2542
- const data = event.data;
2543
- if (data && typeof data.sceneId === "string" && data.sceneId.length <= 200 && typeof data.narration === "string" && data.narration.trim() && data.narration.length <= 2e3 && speechLoads.size < 32) {
2544
- void prepareSpeech(data.narration.trim(), controller.signal).catch(() => void 0);
2545
- }
3156
+ preparation.announce(event.data);
2546
3157
  continue;
2547
3158
  }
2548
3159
  if (event.type === "data.video-chat-opening") {
@@ -2572,22 +3183,7 @@ function useVideoChatSession(options = {}) {
2572
3183
  const plannedScene = event.data.scene;
2573
3184
  planned[position] = plannedScene;
2574
3185
  received[position] = plannedScene;
2575
- const rendererReady = currentOptions.templates?.getTemplate(plannedScene.templateId) ? Promise.resolve() : Promise.resolve(preloadBuiltinTemplate(plannedScene.templateId));
2576
- const visualPreparation = rendererReady.then(async () => {
2577
- try {
2578
- await prepareSceneMedia(plannedScene.variables, controller.signal);
2579
- return plannedScene;
2580
- } catch (cause) {
2581
- if (controller.signal.aborted) throw cause;
2582
- const override = currentOptions.templates?.getTemplate(plannedScene.templateId);
2583
- const fallback = !override || supportsExternalVideoBackdrop(override) ? recoverSceneMedia(plannedScene) : void 0;
2584
- if (!fallback) throw new VideoError("Scene could not prepare its visual", { code: "media_not_ready" });
2585
- warn(MEDIA_RECOVERY_NOTICE);
2586
- return fallback;
2587
- }
2588
- });
2589
- void visualPreparation.catch(() => void 0);
2590
- warmSceneMedia(plannedScene.variables);
3186
+ const visualPreparation = preparation.prepareVisual(plannedScene);
2591
3187
  const narrated = narrating.then(async () => {
2592
3188
  const supplied = plannedScene.narration?.trim();
2593
3189
  if (supplied) return supplied;
@@ -2623,7 +3219,7 @@ function useVideoChatSession(options = {}) {
2623
3219
  const withNarration = line ? { ...visual, narration: line } : visual;
2624
3220
  const group = plannedScene.narrationGroup;
2625
3221
  if (group && (spoken?.supportsOffsets !== true || voiceRef.current.supportsOffsets !== true || Math.abs(spoken.seconds - group.totalSeconds) > 0.1)) throw new VideoError("Narration group requires matching measured audio with offset support", { code: "narration_group_invalid" });
2626
- ready[position] = group ? withNarration : pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
3222
+ ready[position] = group ? withNarration : preparation.pace(withNarration, spoken?.seconds);
2627
3223
  flush();
2628
3224
  }).catch((cause) => {
2629
3225
  if (!isCurrent() || currentAttempt !== attempt) return;
@@ -2664,7 +3260,7 @@ function useVideoChatSession(options = {}) {
2664
3260
  try {
2665
3261
  response = await untilAborted(runAttempt(attempt), controller.signal);
2666
3262
  } catch (cause) {
2667
- if (controller.signal.aborted || timeline || spokenHook || cause instanceof VideoError && ["media_not_ready", "narration_group_invalid"].includes(cause.code) || received.some((scene) => scene?.narrationGroup)) throw cause;
3263
+ if (mode !== "pexels" || controller.signal.aborted || timeline || spokenHook || cause instanceof VideoError && ["media_not_ready", "narration_group_invalid"].includes(cause.code) || received.some((scene) => scene?.narrationGroup)) throw cause;
2668
3264
  attempt += 1;
2669
3265
  ready = [];
2670
3266
  received = [];
@@ -2727,19 +3323,19 @@ function useVideoChatSession(options = {}) {
2727
3323
  if (runRef.current === run) inFlightRef.current = void 0;
2728
3324
  }
2729
3325
  }, [request, endTiming]);
2730
- const pause = useCallback2(() => {
3326
+ const pause = useCallback3(() => {
2731
3327
  finishStall();
2732
3328
  heldRef.current = true;
2733
3329
  voiceRef.current.pause();
2734
3330
  dispatch({ type: "pause" });
2735
3331
  }, [finishStall]);
2736
- const resume = useCallback2(() => {
3332
+ const resume = useCallback3(() => {
2737
3333
  heldRef.current = false;
2738
3334
  voiceRef.current.resume();
2739
3335
  dispatch({ type: "resume" });
2740
3336
  flushRef.current?.();
2741
3337
  }, []);
2742
- const prepareSavedGroups = useCallback2(async (video) => {
3338
+ const prepareSavedGroups = useCallback3(async (video) => {
2743
3339
  validateNarrationGroups(video.scenes);
2744
3340
  const groups = new Map(video.scenes.flatMap((scene) => scene.narrationGroup ? [[scene.narrationGroup.id, scene.narrationGroup]] : []));
2745
3341
  for (const group of groups.values()) {
@@ -2747,7 +3343,7 @@ function useVideoChatSession(options = {}) {
2747
3343
  if (voiceRef.current.supportsOffsets !== true || prepared.supportsOffsets !== true || Math.abs(prepared.seconds - group.totalSeconds) > 0.1) throw new VideoError("Saved narration group requires matching measured audio", { code: "narration_group_invalid" });
2748
3344
  }
2749
3345
  }, []);
2750
- const replay = useCallback2(() => {
3346
+ const replay = useCallback3(() => {
2751
3347
  const turn = stateRef.current.turns.find((entry) => entry.id === stateRef.current.shownTurnId);
2752
3348
  if (!turn?.completed || !turn.video) return;
2753
3349
  runRef.current += 1;
@@ -2774,7 +3370,7 @@ function useVideoChatSession(options = {}) {
2774
3370
  });
2775
3371
  } else dispatch({ type: "replay" });
2776
3372
  }, [endTiming, prepareSavedGroups]);
2777
- const selectTurn = useCallback2((id) => {
3373
+ const selectTurn = useCallback3((id) => {
2778
3374
  const turn = stateRef.current.turns.find((entry) => entry.id === id);
2779
3375
  if (!turn?.completed || !turn.video) return;
2780
3376
  runRef.current += 1;
@@ -2801,19 +3397,19 @@ function useVideoChatSession(options = {}) {
2801
3397
  });
2802
3398
  } else dispatch({ type: "select", id });
2803
3399
  }, [endTiming, prepareSavedGroups]);
2804
- const reset = useCallback2(() => {
3400
+ const reset = useCallback3(() => {
2805
3401
  cancel("Session reset");
2806
3402
  heldRef.current = false;
2807
3403
  voiceRef.current.resume();
2808
3404
  dispatch({ type: "reset" });
2809
3405
  }, [cancel]);
2810
- const restoreSession = useCallback2((turns) => {
3406
+ const restoreSession = useCallback3((turns) => {
2811
3407
  cancel("Restoring session");
2812
3408
  heldRef.current = false;
2813
3409
  voiceRef.current.resume();
2814
3410
  dispatch({ type: "restore", turns: turns.filter((turn) => turn.completed && turn.video).slice(-100) });
2815
3411
  }, [cancel]);
2816
- const setMuted = useCallback2((muted) => dispatch({ type: "mute", value: muted }), []);
3412
+ const setMuted = useCallback3((muted) => dispatch({ type: "mute", value: muted }), []);
2817
3413
  const currentTurn = state.turns.at(-1);
2818
3414
  const shownTurn = state.turns.find((turn) => turn.id === state.shownTurnId) ?? currentTurn;
2819
3415
  const availableModes = state.capabilities?.modes ?? ["cinematic"];
@@ -2822,13 +3418,13 @@ function useVideoChatSession(options = {}) {
2822
3418
  const transcript = shownTurn && shownTurn === currentTurn && state.playback?.kind !== "video" ? fullTranscript.slice(0, (shownTurn.opening ? 1 : 0) + state.spokenUpTo + 1) : fullTranscript;
2823
3419
  const playbackKey = state.playerKey;
2824
3420
  const playerProps = state.playback ? {
2825
- ...options.templates ? { templates: options.templates } : {},
2826
3421
  ...state.playback.kind === "stream" ? { stream: state.playback.stream } : { video: state.playback.video },
2827
3422
  autoPlay: true,
2828
3423
  paused: state.status === "paused",
2829
3424
  controls: false,
2830
3425
  narrationReady: narration.isReady,
2831
3426
  narrationTime: narration.getTime,
3427
+ narrationActive: narration.isSpeaking,
2832
3428
  orientation: shownTurn?.fixedOrientation ? shownTurn.orientation : "auto",
2833
3429
  onFramePresented: () => {
2834
3430
  const timing = firstFrameRef.current;
@@ -2849,15 +3445,23 @@ function useVideoChatSession(options = {}) {
2849
3445
  elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt))
2850
3446
  });
2851
3447
  },
2852
- onStallChange: (stalled) => {
3448
+ onPlaybackMetric: options.onPlaybackMetric ? (metric) => {
3449
+ const timing = firstFrameRef.current;
3450
+ if (stateRef.current.playerKey !== playbackKey || !timing?.active || state.playback?.kind !== "stream") return;
3451
+ reportMetric({ ...metric, turnId: timing.turnId, mode: timing.mode, elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt)) });
3452
+ } : void 0,
3453
+ onStallChange: (stalled, reason = "scene-generation") => {
2853
3454
  if (stateRef.current.playerKey !== playbackKey) return;
2854
- if (stalled) voiceRef.current.pause();
3455
+ if (stalled && reason !== "speech") voiceRef.current.pause();
2855
3456
  else if (!heldRef.current) voiceRef.current.resume();
2856
3457
  if (state.playback?.kind !== "stream") return;
2857
3458
  const timing = firstFrameRef.current;
2858
3459
  if (!timing?.active || !timing.reported) return;
2859
3460
  if (!stalled) finishStall();
2860
- else if (!heldRef.current && timing.stallStartedAt == null) timing.stallStartedAt = monotonicNow();
3461
+ else if (!heldRef.current && timing.stallStartedAt == null) {
3462
+ timing.stallStartedAt = monotonicNow();
3463
+ timing.stallReason = reason;
3464
+ }
2861
3465
  },
2862
3466
  onSceneChange: (scene, index) => {
2863
3467
  if (stateRef.current.playerKey !== playbackKey) return;
@@ -2907,8 +3511,8 @@ function useVideoChatSession(options = {}) {
2907
3511
  }
2908
3512
 
2909
3513
  // src/video-chat/caption-pages.tsx
2910
- import { useEffect as useEffect5, useLayoutEffect, useRef as useRef4, useState as useState3 } from "react";
2911
- import { jsx as jsx3 } from "react/jsx-runtime";
3514
+ import { useEffect as useEffect8, useLayoutEffect, useRef as useRef7, useState as useState4 } from "react";
3515
+ import { jsx as jsx5 } from "react/jsx-runtime";
2912
3516
  function splitCaptionPages(text, fits) {
2913
3517
  const pages = [];
2914
3518
  let rest = Array.from(text);
@@ -2941,14 +3545,14 @@ function captionPageAt(pages, elapsed, duration) {
2941
3545
  return Math.max(0, pages.length - 1);
2942
3546
  }
2943
3547
  function CaptionPages({ text, getProgress }) {
2944
- const line = useRef4(null);
2945
- const lastProgress = useRef4(void 0);
2946
- const getter = useRef4(getProgress);
3548
+ const line = useRef7(null);
3549
+ const lastProgress = useRef7(void 0);
3550
+ const getter = useRef7(getProgress);
2947
3551
  getter.current = getProgress;
2948
- const [source, setSource] = useState3(text);
2949
- const [pages, setPages] = useState3([]);
2950
- const [index, setIndex] = useState3(0);
2951
- const [timing, setTiming] = useState3();
3552
+ const [source, setSource] = useState4(text);
3553
+ const [pages, setPages] = useState4([]);
3554
+ const [index, setIndex] = useState4(0);
3555
+ const [timing, setTiming] = useState4();
2952
3556
  useLayoutEffect(() => {
2953
3557
  const element = line.current;
2954
3558
  if (!element) return;
@@ -2991,7 +3595,7 @@ function CaptionPages({ text, getProgress }) {
2991
3595
  document.fonts?.removeEventListener("loadingdone", measure);
2992
3596
  };
2993
3597
  }, [source]);
2994
- useEffect5(() => {
3598
+ useEffect8(() => {
2995
3599
  let frame = 0;
2996
3600
  const update = () => {
2997
3601
  const observed = text ? getter.current() : void 0;
@@ -3006,14 +3610,14 @@ function CaptionPages({ text, getProgress }) {
3006
3610
  update();
3007
3611
  return () => cancelAnimationFrame(frame);
3008
3612
  }, [text, source, pages]);
3009
- return /* @__PURE__ */ jsx3("p", { ref: line, className: "line", "aria-live": "polite", "data-caption-timing": timing, "data-caption-page": index, children: pages[index] ?? "" });
3613
+ return /* @__PURE__ */ jsx5("p", { ref: line, className: "line", "aria-live": "polite", "data-caption-timing": timing, "data-caption-page": index, children: pages[index] ?? "" });
3010
3614
  }
3011
3615
 
3012
3616
  // src/video-chat/video-chat.tsx
3013
- import { useCallback as useCallback5, useEffect as useEffect10, useId as useId2, useLayoutEffect as useLayoutEffect3, useMemo as useMemo3, useRef as useRef9, useState as useState8 } from "react";
3617
+ import { useCallback as useCallback6, useEffect as useEffect13, useId as useId2, useLayoutEffect as useLayoutEffect3, useMemo as useMemo3, useRef as useRef12, useState as useState9 } from "react";
3014
3618
 
3015
3619
  // src/video-chat/icons.tsx
3016
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
3620
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
3017
3621
  var stroke = {
3018
3622
  fill: "none",
3019
3623
  stroke: "currentColor",
@@ -3022,50 +3626,50 @@ var stroke = {
3022
3626
  strokeLinejoin: "round"
3023
3627
  };
3024
3628
  function Glyph({ children }) {
3025
- return /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", ...stroke, children });
3629
+ return /* @__PURE__ */ jsx6("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", ...stroke, children });
3026
3630
  }
3027
- var Sessions = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3028
- /* @__PURE__ */ jsx4("path", { d: "M5 3h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8l-5 3V5a2 2 0 0 1 2-2Z" }),
3029
- /* @__PURE__ */ jsx4("path", { d: "M17 8h2a2 2 0 0 1 2 2v11l-4-3h-6a2 2 0 0 1-2-2v-2" })
3631
+ var Sessions = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3632
+ /* @__PURE__ */ jsx6("path", { d: "M5 3h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8l-5 3V5a2 2 0 0 1 2-2Z" }),
3633
+ /* @__PURE__ */ jsx6("path", { d: "M17 8h2a2 2 0 0 1 2 2v11l-4-3h-6a2 2 0 0 1-2-2v-2" })
3030
3634
  ] });
3031
- var Plus = () => /* @__PURE__ */ jsx4(Glyph, { children: /* @__PURE__ */ jsx4("path", { d: "M12 5v14M5 12h14" }) });
3032
- var Gear = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3033
- /* @__PURE__ */ jsx4("circle", { cx: "12", cy: "12", r: "3" }),
3034
- /* @__PURE__ */ jsx4("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" })
3635
+ var Plus = () => /* @__PURE__ */ jsx6(Glyph, { children: /* @__PURE__ */ jsx6("path", { d: "M12 5v14M5 12h14" }) });
3636
+ var Gear = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3637
+ /* @__PURE__ */ jsx6("circle", { cx: "12", cy: "12", r: "3" }),
3638
+ /* @__PURE__ */ jsx6("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" })
3035
3639
  ] });
3036
- var Sound = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3037
- /* @__PURE__ */ jsx4("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3038
- /* @__PURE__ */ jsx4("path", { d: "M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" })
3640
+ var Sound = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3641
+ /* @__PURE__ */ jsx6("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3642
+ /* @__PURE__ */ jsx6("path", { d: "M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" })
3039
3643
  ] });
3040
- var Muted = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3041
- /* @__PURE__ */ jsx4("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3042
- /* @__PURE__ */ jsx4("path", { d: "m22 9-6 6M16 9l6 6" })
3644
+ var Muted = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3645
+ /* @__PURE__ */ jsx6("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3646
+ /* @__PURE__ */ jsx6("path", { d: "m22 9-6 6M16 9l6 6" })
3043
3647
  ] });
3044
- var Mic = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3045
- /* @__PURE__ */ jsx4("rect", { x: "9", y: "2", width: "6", height: "12", rx: "3" }),
3046
- /* @__PURE__ */ jsx4("path", { d: "M5 11a7 7 0 0 0 14 0M12 18v4" })
3648
+ var Mic = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3649
+ /* @__PURE__ */ jsx6("rect", { x: "9", y: "2", width: "6", height: "12", rx: "3" }),
3650
+ /* @__PURE__ */ jsx6("path", { d: "M5 11a7 7 0 0 0 14 0M12 18v4" })
3047
3651
  ] });
3048
- var Send = () => /* @__PURE__ */ jsx4(Glyph, { children: /* @__PURE__ */ jsx4("path", { d: "M12 19V5M5 12l7-7 7 7" }) });
3049
- var Stop = () => /* @__PURE__ */ jsxs3("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: [
3050
- /* @__PURE__ */ jsx4("rect", { x: "7", y: "5", width: "3.4", height: "14", rx: "1.4" }),
3051
- /* @__PURE__ */ jsx4("rect", { x: "13.6", y: "5", width: "3.4", height: "14", rx: "1.4" })
3652
+ var Send = () => /* @__PURE__ */ jsx6(Glyph, { children: /* @__PURE__ */ jsx6("path", { d: "M12 19V5M5 12l7-7 7 7" }) });
3653
+ var Stop = () => /* @__PURE__ */ jsxs4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: [
3654
+ /* @__PURE__ */ jsx6("rect", { x: "7", y: "5", width: "3.4", height: "14", rx: "1.4" }),
3655
+ /* @__PURE__ */ jsx6("rect", { x: "13.6", y: "5", width: "3.4", height: "14", rx: "1.4" })
3052
3656
  ] });
3053
- var Play = () => /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: /* @__PURE__ */ jsx4("path", { d: "M8 5.5v13l11-6.5z" }) });
3054
- var Replay = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3055
- /* @__PURE__ */ jsx4("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }),
3056
- /* @__PURE__ */ jsx4("path", { d: "M3 3v5h5" })
3657
+ var Play = () => /* @__PURE__ */ jsx6("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: /* @__PURE__ */ jsx6("path", { d: "M8 5.5v13l11-6.5z" }) });
3658
+ var Replay = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3659
+ /* @__PURE__ */ jsx6("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }),
3660
+ /* @__PURE__ */ jsx6("path", { d: "M3 3v5h5" })
3057
3661
  ] });
3058
- var ChevronUp = () => /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", ...stroke, children: /* @__PURE__ */ jsx4("path", { d: "m6 15 6-6 6 6" }) });
3059
- var Close = () => /* @__PURE__ */ jsx4(Glyph, { children: /* @__PURE__ */ jsx4("path", { d: "M6 6l12 12M18 6 6 18" }) });
3060
- var Warning = () => /* @__PURE__ */ jsxs3(Glyph, { children: [
3061
- /* @__PURE__ */ jsx4("circle", { cx: "12", cy: "12", r: "9" }),
3062
- /* @__PURE__ */ jsx4("path", { d: "M12 8v5M12 16.2v.1" })
3662
+ var ChevronUp = () => /* @__PURE__ */ jsx6("svg", { viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", ...stroke, children: /* @__PURE__ */ jsx6("path", { d: "m6 15 6-6 6 6" }) });
3663
+ var Close = () => /* @__PURE__ */ jsx6(Glyph, { children: /* @__PURE__ */ jsx6("path", { d: "M6 6l12 12M18 6 6 18" }) });
3664
+ var Warning = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3665
+ /* @__PURE__ */ jsx6("circle", { cx: "12", cy: "12", r: "9" }),
3666
+ /* @__PURE__ */ jsx6("path", { d: "M12 8v5M12 16.2v.1" })
3063
3667
  ] });
3064
3668
 
3065
3669
  // src/video-chat/use-dismiss.ts
3066
- import { useEffect as useEffect6 } from "react";
3670
+ import { useEffect as useEffect9 } from "react";
3067
3671
  function useDismiss(open, close, surfaces) {
3068
- useEffect6(() => {
3672
+ useEffect9(() => {
3069
3673
  if (!open) return;
3070
3674
  const onKeyDown = (event) => {
3071
3675
  if (event.key !== "Escape") return;
@@ -3087,7 +3691,7 @@ function useDismiss(open, close, surfaces) {
3087
3691
  }, [open, close, surfaces]);
3088
3692
  }
3089
3693
  function useFocusTrap(active, surface) {
3090
- useEffect6(() => {
3694
+ useEffect9(() => {
3091
3695
  if (!active) return;
3092
3696
  const returnTo = document.activeElement;
3093
3697
  const focusable = () => Array.from(
@@ -3120,12 +3724,12 @@ function useFocusTrap(active, surface) {
3120
3724
  }
3121
3725
 
3122
3726
  // src/video-chat/suggestion-cards.tsx
3123
- import { useEffect as useEffect7, useId, useRef as useRef5, useState as useState4 } from "react";
3124
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
3727
+ import { useEffect as useEffect10, useId, useRef as useRef8, useState as useState5 } from "react";
3728
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
3125
3729
  function Frame({ media, poster, playing, onReady, onError, revealWhenReady = false }) {
3126
- const video = useRef5(null);
3127
- const [playingUrl, setPlayingUrl] = useState4(null);
3128
- useEffect7(() => {
3730
+ const video = useRef8(null);
3731
+ const [playingUrl, setPlayingUrl] = useState5(null);
3732
+ useEffect10(() => {
3129
3733
  const element = video.current;
3130
3734
  if (!element || playing === void 0) return;
3131
3735
  if (playing) void element.play().catch(() => void 0);
@@ -3133,16 +3737,16 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
3133
3737
  }, [playing]);
3134
3738
  if (!media) return null;
3135
3739
  if (playing === false && media.type === "video" && media.posterUrl) {
3136
- return /* @__PURE__ */ jsx5("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady, onError });
3740
+ return /* @__PURE__ */ jsx7("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady, onError });
3137
3741
  }
3138
3742
  const ready = () => {
3139
3743
  setPlayingUrl(media.url);
3140
3744
  onReady?.();
3141
3745
  };
3142
3746
  const appearance = revealWhenReady ? { opacity: playingUrl === media.url ? 1 : 0, transition: "opacity 200ms ease" } : void 0;
3143
- if (media.type === "image") return /* @__PURE__ */ jsx5("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
3144
- return /* @__PURE__ */ jsxs4(Fragment2, { children: [
3145
- /* @__PURE__ */ jsx5(
3747
+ if (media.type === "image") return /* @__PURE__ */ jsx7("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
3748
+ return /* @__PURE__ */ jsxs5(Fragment2, { children: [
3749
+ /* @__PURE__ */ jsx7(
3146
3750
  "video",
3147
3751
  {
3148
3752
  ref: video,
@@ -3163,15 +3767,15 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
3163
3767
  "aria-hidden": "true"
3164
3768
  }
3165
3769
  ),
3166
- poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx5("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3770
+ poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx7("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3167
3771
  ] });
3168
3772
  }
3169
3773
  function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3170
- const railRef = useRef5(null);
3774
+ const railRef = useRef8(null);
3171
3775
  const railId = useId();
3172
- const [at, setAt] = useState4(0);
3173
- const [overflow, setOverflow] = useState4({ before: false, after: false });
3174
- useEffect7(() => {
3776
+ const [at, setAt] = useState5(0);
3777
+ const [overflow, setOverflow] = useState5({ before: false, after: false });
3778
+ useEffect10(() => {
3175
3779
  const rail = railRef.current;
3176
3780
  if (!browse || !rail) return;
3177
3781
  const update = () => setOverflow({ before: rail.scrollLeft > 2, after: rail.scrollLeft + rail.clientWidth < rail.scrollWidth - 2 });
@@ -3190,8 +3794,8 @@ function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3190
3794
  rail.scrollBy({ left: direction * rail.clientWidth, behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
3191
3795
  };
3192
3796
  if (suggestions.length === 0) return null;
3193
- const cards = /* @__PURE__ */ jsxs4(Fragment2, { children: [
3194
- /* @__PURE__ */ jsx5("ul", { className: "cards", id: railId, ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs4(
3797
+ const cards = /* @__PURE__ */ jsxs5(Fragment2, { children: [
3798
+ /* @__PURE__ */ jsx7("ul", { className: "cards", id: railId, ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsxs5(
3195
3799
  "button",
3196
3800
  {
3197
3801
  type: "button",
@@ -3200,13 +3804,13 @@ function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3200
3804
  onPointerEnter: () => setAt(index),
3201
3805
  onClick: () => onAsk(card),
3202
3806
  children: [
3203
- /* @__PURE__ */ jsx5(Frame, { media: card.media, poster: true, playing: index === at }),
3204
- /* @__PURE__ */ jsx5("span", { className: "card-wash", "aria-hidden": "true" }),
3205
- /* @__PURE__ */ jsx5("span", { className: "card-prompt", children: card.prompt })
3807
+ /* @__PURE__ */ jsx7(Frame, { media: card.media, poster: true, playing: index === at }),
3808
+ /* @__PURE__ */ jsx7("span", { className: "card-wash", "aria-hidden": "true" }),
3809
+ /* @__PURE__ */ jsx7("span", { className: "card-prompt", children: card.prompt })
3206
3810
  ]
3207
3811
  }
3208
3812
  ) }, card.prompt)) }),
3209
- suggestions.length > 1 && /* @__PURE__ */ jsx5("div", { className: "card-dots", role: "group", "aria-label": `${label} navigation`, children: suggestions.map((card, index) => /* @__PURE__ */ jsx5(
3813
+ suggestions.length > 1 && /* @__PURE__ */ jsx7("div", { className: "card-dots", role: "group", "aria-label": `${label} navigation`, children: suggestions.map((card, index) => /* @__PURE__ */ jsx7(
3210
3814
  "button",
3211
3815
  {
3212
3816
  type: "button",
@@ -3223,39 +3827,39 @@ function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3223
3827
  card.prompt
3224
3828
  )) })
3225
3829
  ] });
3226
- return browse ? /* @__PURE__ */ jsxs4("div", { className: "suggestion-rail", children: [
3830
+ return browse ? /* @__PURE__ */ jsxs5("div", { className: "suggestion-rail", children: [
3227
3831
  cards,
3228
- (overflow.before || overflow.after) && /* @__PURE__ */ jsxs4("div", { className: "rail-arrows", children: [
3229
- /* @__PURE__ */ jsx5("button", { type: "button", "aria-label": "Previous suggestions", "aria-controls": railId, disabled: !overflow.before, onClick: () => move(-1), children: "\u2039" }),
3230
- /* @__PURE__ */ jsx5("button", { type: "button", "aria-label": "Next suggestions", "aria-controls": railId, disabled: !overflow.after, onClick: () => move(1), children: "\u203A" })
3832
+ (overflow.before || overflow.after) && /* @__PURE__ */ jsxs5("div", { className: "rail-arrows", children: [
3833
+ /* @__PURE__ */ jsx7("button", { type: "button", "aria-label": "Previous suggestions", "aria-controls": railId, disabled: !overflow.before, onClick: () => move(-1), children: "\u2039" }),
3834
+ /* @__PURE__ */ jsx7("button", { type: "button", "aria-label": "Next suggestions", "aria-controls": railId, disabled: !overflow.after, onClick: () => move(1), children: "\u203A" })
3231
3835
  ] })
3232
3836
  ] }) : cards;
3233
3837
  }
3234
3838
 
3235
3839
  // src/video-chat/welcome.tsx
3236
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3840
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3237
3841
  function Welcome({ data, onAsk, title }) {
3238
- return /* @__PURE__ */ jsxs5("div", { className: "welcome", children: [
3239
- /* @__PURE__ */ jsx6(Frame, { media: data?.hero ?? null, poster: true }),
3240
- /* @__PURE__ */ jsx6("div", { className: "welcome-wash", "aria-hidden": "true" }),
3241
- /* @__PURE__ */ jsxs5("div", { className: "welcome-body", children: [
3242
- /* @__PURE__ */ jsx6("h1", { className: "welcome-title", children: title ?? /* @__PURE__ */ jsxs5(Fragment3, { children: [
3842
+ return /* @__PURE__ */ jsxs6("div", { className: "welcome", children: [
3843
+ /* @__PURE__ */ jsx8(Frame, { media: data?.hero ?? null, poster: true }),
3844
+ /* @__PURE__ */ jsx8("div", { className: "welcome-wash", "aria-hidden": "true" }),
3845
+ /* @__PURE__ */ jsxs6("div", { className: "welcome-body", children: [
3846
+ /* @__PURE__ */ jsx8("h1", { className: "welcome-title", children: title ?? /* @__PURE__ */ jsxs6(Fragment3, { children: [
3243
3847
  "An AI chat that responds",
3244
- /* @__PURE__ */ jsx6("br", {}),
3245
- /* @__PURE__ */ jsx6("em", { children: "in video, not text." })
3848
+ /* @__PURE__ */ jsx8("br", {}),
3849
+ /* @__PURE__ */ jsx8("em", { children: "in video, not text." })
3246
3850
  ] }) }),
3247
- /* @__PURE__ */ jsx6(SuggestionCards, { browse: true, suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
3851
+ /* @__PURE__ */ jsx8(SuggestionCards, { browse: true, suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
3248
3852
  ] })
3249
3853
  ] });
3250
3854
  }
3251
3855
 
3252
3856
  // src/video-chat/opening-chapter.tsx
3253
- import { useLayoutEffect as useLayoutEffect2, useRef as useRef6, useState as useState5 } from "react";
3254
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3857
+ import { useLayoutEffect as useLayoutEffect2, useRef as useRef9, useState as useState6 } from "react";
3858
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3255
3859
  function OpeningChapter({ title, preparing = false }) {
3256
- const root = useRef6(null);
3257
- const [size, setSize] = useState5({ width: 1080, height: 1080 });
3258
- const [titleBottom, setTitleBottom] = useState5(0);
3860
+ const root = useRef9(null);
3861
+ const [size, setSize] = useState6({ width: 1080, height: 1080 });
3862
+ const [titleBottom, setTitleBottom] = useState6(0);
3259
3863
  useLayoutEffect2(() => {
3260
3864
  const element = root.current;
3261
3865
  if (!element) return;
@@ -3273,28 +3877,17 @@ function OpeningChapter({ title, preparing = false }) {
3273
3877
  if (title2) observer.observe(title2);
3274
3878
  return () => observer.disconnect();
3275
3879
  }, [title]);
3276
- return /* @__PURE__ */ jsxs6("div", { ref: root, "data-opening-chapter": true, className: "opening-chapter", children: [
3277
- /* @__PURE__ */ jsx7(
3278
- TitleSceneTemplate,
3279
- {
3280
- variables: { title },
3281
- style: {},
3282
- width: size.width,
3283
- height: size.height,
3284
- progress: 0.3,
3285
- beatIntensity: 0,
3286
- safeZone: { top: 0, right: 0, bottom: 0, left: 0 }
3287
- }
3288
- ),
3289
- preparing && /* @__PURE__ */ jsxs6("div", { className: "video-preparation", style: { top: titleBottom + 24 }, role: "status", "aria-label": "Video preparation", children: [
3290
- /* @__PURE__ */ jsx7("span", { className: "video-preparation-spinner", "aria-hidden": "true" }),
3291
- /* @__PURE__ */ jsx7("span", { className: "video-preparation-text", children: "Preparing your video\u2026" })
3880
+ return /* @__PURE__ */ jsxs7("div", { ref: root, "data-opening-chapter": true, className: "opening-chapter", children: [
3881
+ /* @__PURE__ */ jsx9(TitleSceneTemplate, { variables: { title }, width: size.width, height: size.height, progress: 0.3 }),
3882
+ preparing && /* @__PURE__ */ jsxs7("div", { className: "video-preparation", style: { top: titleBottom + 24 }, role: "status", "aria-label": "Video preparation", children: [
3883
+ /* @__PURE__ */ jsx9("span", { className: "video-preparation-spinner", "aria-hidden": "true" }),
3884
+ /* @__PURE__ */ jsx9("span", { className: "video-preparation-text", children: "Preparing your video\u2026" })
3292
3885
  ] })
3293
3886
  ] });
3294
3887
  }
3295
3888
 
3296
3889
  // src/video-chat/use-voice-input.ts
3297
- import { useCallback as useCallback3, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
3890
+ import { useCallback as useCallback4, useEffect as useEffect11, useRef as useRef10, useState as useState7 } from "react";
3298
3891
  function recognitionConstructor() {
3299
3892
  if (typeof window === "undefined") return void 0;
3300
3893
  const holder = window;
@@ -3364,19 +3957,19 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3364
3957
  return clip ? transcribeRecording(clip, signal, options) : "";
3365
3958
  }
3366
3959
  function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {}) {
3367
- const [supported, setSupported] = useState6(false);
3368
- const [listening, setListening] = useState6(false);
3369
- const [error, setError] = useState6();
3370
- const [thinking, setThinking] = useState6(false);
3371
- const recognitionRef = useRef7(void 0);
3372
- const useRecorderRef = useRef7(false);
3373
- const operationRef = useRef7(void 0);
3374
- const handlerRef = useRef7(onTranscript);
3960
+ const [supported, setSupported] = useState7(false);
3961
+ const [listening, setListening] = useState7(false);
3962
+ const [error, setError] = useState7();
3963
+ const [thinking, setThinking] = useState7(false);
3964
+ const recognitionRef = useRef10(void 0);
3965
+ const useRecorderRef = useRef10(false);
3966
+ const operationRef = useRef10(void 0);
3967
+ const handlerRef = useRef10(onTranscript);
3375
3968
  handlerRef.current = onTranscript;
3376
- useEffect8(() => {
3969
+ useEffect11(() => {
3377
3970
  setSupported(supportsVoiceInput(transcriptionAvailable));
3378
3971
  }, [transcriptionAvailable]);
3379
- const stop = useCallback3(() => {
3972
+ const stop = useCallback4(() => {
3380
3973
  recognitionRef.current?.abort();
3381
3974
  recognitionRef.current = void 0;
3382
3975
  const operation = operationRef.current;
@@ -3386,7 +3979,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3386
3979
  setListening(false);
3387
3980
  setThinking(false);
3388
3981
  }, []);
3389
- useEffect8(() => () => {
3982
+ useEffect11(() => () => {
3390
3983
  recognitionRef.current?.abort();
3391
3984
  recognitionRef.current = void 0;
3392
3985
  const operation = operationRef.current;
@@ -3394,7 +3987,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3394
3987
  operation?.controller.abort();
3395
3988
  operation?.finish?.();
3396
3989
  }, []);
3397
- const finish = useCallback3(() => {
3990
+ const finish = useCallback4(() => {
3398
3991
  const recognition = recognitionRef.current;
3399
3992
  if (recognition) {
3400
3993
  recognition.stop();
@@ -3408,7 +4001,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3408
4001
  }
3409
4002
  stop();
3410
4003
  }, [stop]);
3411
- const record = useCallback3(async () => {
4004
+ const record = useCallback4(async () => {
3412
4005
  if (!transcriptionAvailable || !recorderAvailable()) {
3413
4006
  setError("Server transcription is not configured.");
3414
4007
  return;
@@ -3446,7 +4039,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3446
4039
  }
3447
4040
  }
3448
4041
  }, [transcriptionAvailable, options.endpoint, options.headers, options.credentials, options.fetcher]);
3449
- const toggle = useCallback3(() => {
4042
+ const toggle = useCallback4(() => {
3450
4043
  if (listening) {
3451
4044
  finish();
3452
4045
  return;
@@ -3498,18 +4091,18 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3498
4091
  }
3499
4092
 
3500
4093
  // src/video-chat/use-immersive-controls.ts
3501
- import { useCallback as useCallback4, useEffect as useEffect9, useRef as useRef8, useState as useState7 } from "react";
4094
+ import { useCallback as useCallback5, useEffect as useEffect12, useRef as useRef11, useState as useState8 } from "react";
3502
4095
  function useImmersiveControls(playing, pinned, hasCaptions = false) {
3503
- const [visible, setVisible] = useState7(true);
3504
- const hovered = useRef8(false);
3505
- const focused = useRef8(false);
3506
- const previousCaptions = useRef8(false);
3507
- const timer = useRef8(null);
3508
- const clearTimer = useCallback4(() => {
4096
+ const [visible, setVisible] = useState8(true);
4097
+ const hovered = useRef11(false);
4098
+ const focused = useRef11(false);
4099
+ const previousCaptions = useRef11(false);
4100
+ const timer = useRef11(null);
4101
+ const clearTimer = useCallback5(() => {
3509
4102
  if (timer.current !== null) clearTimeout(timer.current);
3510
4103
  timer.current = null;
3511
4104
  }, []);
3512
- const reveal = useCallback4(() => {
4105
+ const reveal = useCallback5(() => {
3513
4106
  clearTimer();
3514
4107
  setVisible(true);
3515
4108
  if (playing && !pinned && !hovered.current && !focused.current) {
@@ -3519,7 +4112,7 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3519
4112
  }, 2e3);
3520
4113
  }
3521
4114
  }, [clearTimer, playing, pinned]);
3522
- useEffect9(() => {
4115
+ useEffect12(() => {
3523
4116
  const firstCaption = hasCaptions && !previousCaptions.current;
3524
4117
  previousCaptions.current = hasCaptions;
3525
4118
  if (firstCaption && playing && !pinned && !focused.current) {
@@ -3531,21 +4124,21 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3531
4124
  }
3532
4125
  return clearTimer;
3533
4126
  }, [hasCaptions, playing, pinned, reveal, clearTimer]);
3534
- const onPointerEnter = useCallback4((event) => {
4127
+ const onPointerEnter = useCallback5((event) => {
3535
4128
  if (event?.pointerType === "touch") return;
3536
4129
  hovered.current = true;
3537
4130
  reveal();
3538
4131
  }, [reveal]);
3539
- const onPointerLeave = useCallback4(() => {
4132
+ const onPointerLeave = useCallback5(() => {
3540
4133
  if (!hovered.current) return;
3541
4134
  hovered.current = false;
3542
4135
  reveal();
3543
4136
  }, [reveal]);
3544
- const onFocusCapture = useCallback4((event) => {
4137
+ const onFocusCapture = useCallback5((event) => {
3545
4138
  focused.current = event.target.matches(":focus-visible");
3546
4139
  reveal();
3547
4140
  }, [reveal]);
3548
- const onBlurCapture = useCallback4((event) => {
4141
+ const onBlurCapture = useCallback5((event) => {
3549
4142
  if (focused.current && !event.currentTarget.contains(event.relatedTarget)) {
3550
4143
  focused.current = false;
3551
4144
  reveal();
@@ -3555,9 +4148,9 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3555
4148
  }
3556
4149
 
3557
4150
  // src/video-chat/logo.tsx
3558
- import { jsx as jsx8 } from "react/jsx-runtime";
4151
+ import { jsx as jsx10 } from "react/jsx-runtime";
3559
4152
  function Logo() {
3560
- return /* @__PURE__ */ jsx8("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
4153
+ return /* @__PURE__ */ jsx10("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
3561
4154
  }
3562
4155
 
3563
4156
  // src/video-chat/modes.ts
@@ -3568,11 +4161,11 @@ var visualModes = [
3568
4161
  var defaultMode = visualModes[0];
3569
4162
 
3570
4163
  // src/video-chat/video-chat.tsx
3571
- import { Fragment as Fragment4, jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
4164
+ import { Fragment as Fragment4, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
3572
4165
  var DESKTOP_WIDTH = 900;
3573
4166
  function useViewportOrientation() {
3574
- const [portrait, setPortrait] = useState8(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
3575
- useEffect10(() => {
4167
+ const [portrait, setPortrait] = useState9(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
4168
+ useEffect13(() => {
3576
4169
  if (typeof window.matchMedia !== "function") return;
3577
4170
  const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`);
3578
4171
  const update = () => setPortrait(query.matches);
@@ -3583,23 +4176,25 @@ function useViewportOrientation() {
3583
4176
  return portrait ? "portrait" : "landscape";
3584
4177
  }
3585
4178
  function Waveform({ active, listening }) {
3586
- return /* @__PURE__ */ jsx9("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx9("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
4179
+ return /* @__PURE__ */ jsx11("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx11("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3587
4180
  }
3588
- function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice = false }) {
3589
- const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState8();
3590
- const [draft, setDraft] = useState8("");
3591
- const [selectedMode, setSelectedMode] = useState8();
3592
- const [savedSessions, setSavedSessions] = useState8([]);
3593
- const [historyOpen, setHistoryOpen] = useState8(false);
3594
- const [settingsOpen, setSettingsOpen] = useState8(false);
3595
- const [aboutOpen, setAboutOpen] = useState8(false);
3596
- const [captionsOn, setCaptionsOn] = useState8(true);
3597
- const [captionsExpanded, setCaptionsExpanded] = useState8(false);
3598
- const [alwaysShowControls, setAlwaysShowControls] = useState8(false);
3599
- const [editing, setEditing] = useState8(false);
3600
- const resumeAfterInput = useRef9(false);
3601
- const panelRef = useRef9(null);
3602
- const composerRef = useRef9(null);
4181
+ function VideoChat({ options = {}, className, welcomeTitle, branding, showRecoveryNotice = false }) {
4182
+ const appName = branding?.name.trim() || "VanillaSky";
4183
+ const customHome = safeHomeUrl(branding?.homeUrl);
4184
+ const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState9();
4185
+ const [draft, setDraft] = useState9("");
4186
+ const [selectedMode, setSelectedMode] = useState9();
4187
+ const [savedSessions, setSavedSessions] = useState9([]);
4188
+ const [historyOpen, setHistoryOpen] = useState9(false);
4189
+ const [settingsOpen, setSettingsOpen] = useState9(false);
4190
+ const [aboutOpen, setAboutOpen] = useState9(false);
4191
+ const [captionsOn, setCaptionsOn] = useState9(true);
4192
+ const [captionsExpanded, setCaptionsExpanded] = useState9(false);
4193
+ const [alwaysShowControls, setAlwaysShowControls] = useState9(false);
4194
+ const [editing, setEditing] = useState9(false);
4195
+ const resumeAfterInput = useRef12(false);
4196
+ const panelRef = useRef12(null);
4197
+ const composerRef = useRef12(null);
3603
4198
  const viewportOrientation = useViewportOrientation();
3604
4199
  const sessionOrientation = options.orientation ?? viewportOrientation;
3605
4200
  const { chat, restoreSession, getCaptionProgress } = useVideoChatSession({
@@ -3607,8 +4202,8 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3607
4202
  orientation: sessionOrientation,
3608
4203
  mode: selectedMode ?? options.mode
3609
4204
  });
3610
- const observedMode = useRef9(void 0);
3611
- useEffect10(() => {
4205
+ const observedMode = useRef12(void 0);
4206
+ useEffect13(() => {
3612
4207
  const turn = chat.currentTurn;
3613
4208
  if (!turn?.mode) {
3614
4209
  observedMode.current = void 0;
@@ -3623,11 +4218,11 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3623
4218
  const historyId = `${instanceId}-history`;
3624
4219
  const settingsId = `${instanceId}-settings`;
3625
4220
  const promptId = `${instanceId}-prompt`;
3626
- const inputRef = useRef9(null);
3627
- const historyRef = useRef9(null);
3628
- const historyButtonRef = useRef9(null);
3629
- const settingsRef = useRef9(null);
3630
- const settingsButtonRef = useRef9(null);
4221
+ const inputRef = useRef12(null);
4222
+ const historyRef = useRef12(null);
4223
+ const historyButtonRef = useRef12(null);
4224
+ const settingsRef = useRef12(null);
4225
+ const settingsButtonRef = useRef12(null);
3631
4226
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3632
4227
  endpoint: options.endpoint,
3633
4228
  headers: options.headers,
@@ -3636,13 +4231,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3636
4231
  });
3637
4232
  const historySurfaces = useMemo3(() => [historyRef, historyButtonRef], []);
3638
4233
  const settingsSurfaces = useMemo3(() => [settingsRef, settingsButtonRef], []);
3639
- const closeHistory = useCallback5(() => setHistoryOpen(false), []);
3640
- const closeSettings = useCallback5(() => setSettingsOpen(false), []);
4234
+ const closeHistory = useCallback6(() => setHistoryOpen(false), []);
4235
+ const closeSettings = useCallback6(() => setSettingsOpen(false), []);
3641
4236
  useDismiss(historyOpen, closeHistory, historySurfaces);
3642
4237
  useDismiss(settingsOpen, closeSettings, settingsSurfaces);
3643
4238
  useFocusTrap(settingsOpen, settingsRef);
3644
4239
  useFocusTrap(historyOpen, historyRef);
3645
- const ask = useCallback5((value) => {
4240
+ const ask = useCallback6((value) => {
3646
4241
  const prompt = (typeof value === "string" ? value : value.prompt).trim();
3647
4242
  if (!prompt) return;
3648
4243
  setDraft("");
@@ -3657,7 +4252,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3657
4252
  opening: value.opening
3658
4253
  });
3659
4254
  }, [chat, listen]);
3660
- const newSession = useCallback5(() => {
4255
+ const newSession = useCallback6(() => {
3661
4256
  if (chat.turns.length === 0) {
3662
4257
  setHistoryOpen(false);
3663
4258
  setSettingsOpen(false);
@@ -3679,13 +4274,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3679
4274
  const shown = chat.shownTurn;
3680
4275
  const showing = chat.playerProps != null;
3681
4276
  const handoffKey = `${shown?.id ?? ""}:${chat.playerKey}`;
3682
- const [presentedBody, setPresentedBody] = useState8();
3683
- const handoff = useRef9({ key: handoffKey, live: showing, active: false, frame: 0 });
4277
+ const [presentedBody, setPresentedBody] = useState9();
4278
+ const handoff = useRef12({ key: handoffKey, live: showing, active: false, frame: 0 });
3684
4279
  const handoffStopped = chat.status === "error" || chat.status === "cancelled";
3685
4280
  const waitingForBody = showing && presentedBody !== handoffKey;
3686
4281
  const openingChapter = Boolean(shown?.prompt) && (!showing || waitingForBody) && chat.status !== "error" && chat.status !== "cancelled" && chat.status !== "ended";
3687
- const [preparingKey, setPreparingKey] = useState8();
3688
- useEffect10(() => {
4282
+ const [preparingKey, setPreparingKey] = useState9();
4283
+ useEffect13(() => {
3689
4284
  setPreparingKey(void 0);
3690
4285
  if (!openingChapter || !shown?.opening || chat.speaking) return;
3691
4286
  const timer = setTimeout(() => setPreparingKey(shown.id), 1e3);
@@ -3724,13 +4319,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3724
4319
  const status = chat.turns.length === 0 ? "idle" : chat.status === "composing" ? "drawing" : chat.status === "playing" ? "narrating" : chat.status === "error" || chat.status === "cancelled" ? "ended" : chat.status;
3725
4320
  const shownOrientation = shown?.orientation ?? sessionOrientation;
3726
4321
  const stageOrientation = shown?.fixedOrientation ? shownOrientation : sessionOrientation;
3727
- useEffect10(() => {
4322
+ useEffect13(() => {
3728
4323
  setCaptionsExpanded(false);
3729
4324
  }, [chat.playbackEnded, shown?.id, chat.playerKey]);
3730
4325
  const line = chat.caption ?? "";
3731
4326
  const fullTranscript = shown?.video ? [shown.opening, ...shown.video.scenes.map((scene) => scene.narration)].filter((entry) => Boolean(entry)) : chat.transcript;
3732
- const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx9(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx9(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx9(Replay, {}) } : void 0;
3733
- const cancelInput = useCallback5(() => {
4327
+ const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx11(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx11(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx11(Replay, {}) } : void 0;
4328
+ const cancelInput = useCallback6(() => {
3734
4329
  listen.stop();
3735
4330
  setDraft("");
3736
4331
  setEditing(false);
@@ -3770,7 +4365,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3770
4365
  observer.observe(composer);
3771
4366
  return () => observer.disconnect();
3772
4367
  }, []);
3773
- return /* @__PURE__ */ jsxs7(
4368
+ return /* @__PURE__ */ jsxs8(
3774
4369
  "div",
3775
4370
  {
3776
4371
  className: `vanillasky-video-chat${className ? ` ${className}` : ""}`,
@@ -3782,14 +4377,24 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3782
4377
  onPointerDown: controls.reveal,
3783
4378
  onKeyDownCapture: controls.reveal,
3784
4379
  children: [
3785
- /* @__PURE__ */ jsxs7("header", { className: "chrome", ...controlEvents, children: [
3786
- /* @__PURE__ */ jsx9("div", { className: "session-brand", children: /* @__PURE__ */ jsx9("a", { className: "home-link", href: "/", "aria-label": "Home", onClick: (event) => {
3787
- if (window.location.pathname !== "/" || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
3788
- event.preventDefault();
3789
- newSession();
3790
- }, children: /* @__PURE__ */ jsx9(Logo, {}) }) }),
3791
- /* @__PURE__ */ jsxs7("div", { className: "group", children: [
3792
- /* @__PURE__ */ jsxs7(
4380
+ /* @__PURE__ */ jsxs8("header", { className: "chrome", ...controlEvents, children: [
4381
+ /* @__PURE__ */ jsx11("div", { className: "session-brand", children: /* @__PURE__ */ jsx11(
4382
+ "a",
4383
+ {
4384
+ className: "home-link",
4385
+ href: customHome ?? "/",
4386
+ "aria-label": branding ? `${appName} home` : "Home",
4387
+ style: branding ? { color: "inherit", textDecoration: "none" } : void 0,
4388
+ onClick: (event) => {
4389
+ if (customHome || window.location.pathname !== "/" || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
4390
+ event.preventDefault();
4391
+ newSession();
4392
+ },
4393
+ children: branding ? branding.logo ?? appName : /* @__PURE__ */ jsx11(Logo, {})
4394
+ }
4395
+ ) }),
4396
+ /* @__PURE__ */ jsxs8("div", { className: "group", children: [
4397
+ /* @__PURE__ */ jsxs8(
3793
4398
  "button",
3794
4399
  {
3795
4400
  ref: historyButtonRef,
@@ -3804,12 +4409,12 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3804
4409
  setSettingsOpen(false);
3805
4410
  },
3806
4411
  children: [
3807
- /* @__PURE__ */ jsx9(Sessions, {}),
3808
- /* @__PURE__ */ jsx9("span", { className: "nav-label", children: "Sessions" })
4412
+ /* @__PURE__ */ jsx11(Sessions, {}),
4413
+ /* @__PURE__ */ jsx11("span", { className: "nav-label", children: "Sessions" })
3809
4414
  ]
3810
4415
  }
3811
4416
  ),
3812
- /* @__PURE__ */ jsx9(
4417
+ /* @__PURE__ */ jsx11(
3813
4418
  "button",
3814
4419
  {
3815
4420
  ref: settingsButtonRef,
@@ -3823,10 +4428,10 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3823
4428
  setSettingsOpen((open) => !open);
3824
4429
  setHistoryOpen(false);
3825
4430
  },
3826
- children: /* @__PURE__ */ jsx9(Gear, {})
4431
+ children: /* @__PURE__ */ jsx11(Gear, {})
3827
4432
  }
3828
4433
  ),
3829
- /* @__PURE__ */ jsx9(
4434
+ /* @__PURE__ */ jsx11(
3830
4435
  "button",
3831
4436
  {
3832
4437
  type: "button",
@@ -3834,50 +4439,49 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3834
4439
  "aria-label": chat.muted ? "Turn the voice on" : "Turn the voice off",
3835
4440
  "aria-pressed": chat.muted,
3836
4441
  onClick: () => chat.setMuted(!chat.muted),
3837
- children: chat.muted ? /* @__PURE__ */ jsx9(Muted, {}) : /* @__PURE__ */ jsx9(Sound, {})
4442
+ children: chat.muted ? /* @__PURE__ */ jsx11(Muted, {}) : /* @__PURE__ */ jsx11(Sound, {})
3838
4443
  }
3839
4444
  )
3840
4445
  ] })
3841
4446
  ] }),
3842
- /* @__PURE__ */ jsxs7("div", { className: "stage-area", children: [
3843
- /* @__PURE__ */ jsxs7("div", { className: "stage", style: { background: "#000" }, children: [
3844
- openingChapter && /* @__PURE__ */ jsx9(OpeningChapter, { preparing: preparingKey === shown.id && !chat.speaking, title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
3845
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx9(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3846
- chat.playerProps && /* @__PURE__ */ jsx9("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx9(
4447
+ /* @__PURE__ */ jsxs8("div", { className: "stage-area", children: [
4448
+ /* @__PURE__ */ jsxs8("div", { className: "stage", style: { background: "#000" }, children: [
4449
+ openingChapter && /* @__PURE__ */ jsx11(OpeningChapter, { preparing: preparingKey === shown.id && !chat.speaking, title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
4450
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx11(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
4451
+ chat.playerProps && /* @__PURE__ */ jsx11("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx11(
3847
4452
  VideoPlayer,
3848
4453
  {
3849
4454
  ...chat.playerProps,
3850
4455
  onSceneChange: cueBody,
3851
- templates: options.templates,
3852
4456
  orientation: stageOrientation,
3853
4457
  responsiveBreakpoint: DESKTOP_WIDTH,
3854
4458
  ariaLabel: chat.playerProps.video ? "Replay" : "The response"
3855
4459
  },
3856
4460
  chat.playerKey
3857
4461
  ) }),
3858
- showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs7("div", { className: "ending", children: [
3859
- /* @__PURE__ */ jsx9("div", { className: "ending-wash", "aria-hidden": "true" }),
3860
- /* @__PURE__ */ jsxs7("div", { className: "ending-body", children: [
3861
- /* @__PURE__ */ jsx9("p", { className: "ending-label", children: "Ask next" }),
3862
- /* @__PURE__ */ jsx9(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
4462
+ showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs8("div", { className: "ending", children: [
4463
+ /* @__PURE__ */ jsx11("div", { className: "ending-wash", "aria-hidden": "true" }),
4464
+ /* @__PURE__ */ jsxs8("div", { className: "ending-body", children: [
4465
+ /* @__PURE__ */ jsx11("p", { className: "ending-label", children: "Ask next" }),
4466
+ /* @__PURE__ */ jsx11(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3863
4467
  ] })
3864
4468
  ] })
3865
4469
  ] }),
3866
- historyOpen && /* @__PURE__ */ jsxs7("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "Sessions", children: [
3867
- /* @__PURE__ */ jsxs7("div", { className: "popover-heading", children: [
3868
- /* @__PURE__ */ jsx9("h2", { children: "Sessions" }),
3869
- /* @__PURE__ */ jsx9("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx9(Close, {}) })
4470
+ historyOpen && /* @__PURE__ */ jsxs8("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "Sessions", children: [
4471
+ /* @__PURE__ */ jsxs8("div", { className: "popover-heading", children: [
4472
+ /* @__PURE__ */ jsx11("h2", { children: "Sessions" }),
4473
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx11(Close, {}) })
3870
4474
  ] }),
3871
- /* @__PURE__ */ jsxs7("button", { type: "button", className: "history-row session-new", "aria-label": "New session", onClick: newSession, children: [
3872
- /* @__PURE__ */ jsx9(Plus, {}),
3873
- /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
4475
+ /* @__PURE__ */ jsxs8("button", { type: "button", className: "history-row session-new", "aria-label": "New session", onClick: newSession, children: [
4476
+ /* @__PURE__ */ jsx11(Plus, {}),
4477
+ /* @__PURE__ */ jsxs8("span", { className: "prompt", children: [
3874
4478
  "New session",
3875
- /* @__PURE__ */ jsx9("small", { children: "Start a fresh conversation" })
4479
+ /* @__PURE__ */ jsx11("small", { children: "Start a fresh conversation" })
3876
4480
  ] })
3877
4481
  ] }),
3878
- /* @__PURE__ */ jsx9("h3", { className: "section-label", children: "Current session" }),
3879
- chat.turns.length === 0 && /* @__PURE__ */ jsx9("p", { className: "history-empty", children: "Your questions will appear here." }),
3880
- chat.turns.map((turn, index) => /* @__PURE__ */ jsxs7(
4482
+ /* @__PURE__ */ jsx11("h3", { className: "section-label", children: "Current session" }),
4483
+ chat.turns.length === 0 && /* @__PURE__ */ jsx11("p", { className: "history-empty", children: "Your questions will appear here." }),
4484
+ chat.turns.map((turn, index) => /* @__PURE__ */ jsxs8(
3881
4485
  "button",
3882
4486
  {
3883
4487
  type: "button",
@@ -3893,18 +4497,18 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3893
4497
  setHistoryOpen(false);
3894
4498
  },
3895
4499
  children: [
3896
- /* @__PURE__ */ jsx9("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3897
- /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
4500
+ /* @__PURE__ */ jsx11("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
4501
+ /* @__PURE__ */ jsxs8("span", { className: "prompt", children: [
3898
4502
  turn.prompt,
3899
- /* @__PURE__ */ jsx9("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
4503
+ /* @__PURE__ */ jsx11("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3900
4504
  ] })
3901
4505
  ]
3902
4506
  },
3903
4507
  turn.id
3904
4508
  )),
3905
- savedSessions.length > 0 && /* @__PURE__ */ jsxs7(Fragment4, { children: [
3906
- /* @__PURE__ */ jsx9("h3", { className: "section-label", children: "Earlier sessions" }),
3907
- savedSessions.map((session) => /* @__PURE__ */ jsxs7("button", { type: "button", className: "history-row", onClick: () => {
4509
+ savedSessions.length > 0 && /* @__PURE__ */ jsxs8(Fragment4, { children: [
4510
+ /* @__PURE__ */ jsx11("h3", { className: "section-label", children: "Earlier sessions" }),
4511
+ savedSessions.map((session) => /* @__PURE__ */ jsxs8("button", { type: "button", className: "history-row", onClick: () => {
3908
4512
  listen.stop();
3909
4513
  setDraft("");
3910
4514
  setEditing(false);
@@ -3915,10 +4519,10 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3915
4519
  setHistoryOpen(false);
3916
4520
  setCaptionsExpanded(false);
3917
4521
  }, children: [
3918
- /* @__PURE__ */ jsx9(Replay, {}),
3919
- /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
4522
+ /* @__PURE__ */ jsx11(Replay, {}),
4523
+ /* @__PURE__ */ jsxs8("span", { className: "prompt", children: [
3920
4524
  session.turns[0]?.prompt,
3921
- /* @__PURE__ */ jsxs7("small", { children: [
4525
+ /* @__PURE__ */ jsxs8("small", { children: [
3922
4526
  session.turns.length,
3923
4527
  " ",
3924
4528
  session.turns.length === 1 ? "answer" : "answers"
@@ -3927,7 +4531,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3927
4531
  ] }, session.id))
3928
4532
  ] })
3929
4533
  ] }),
3930
- settingsOpen && /* @__PURE__ */ jsxs7(
4534
+ settingsOpen && /* @__PURE__ */ jsxs8(
3931
4535
  "div",
3932
4536
  {
3933
4537
  ref: settingsRef,
@@ -3937,18 +4541,18 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3937
4541
  "aria-label": "Settings",
3938
4542
  "aria-modal": "true",
3939
4543
  children: [
3940
- /* @__PURE__ */ jsxs7("div", { className: "popover-heading", children: [
3941
- /* @__PURE__ */ jsx9("h2", { children: "Settings" }),
3942
- /* @__PURE__ */ jsx9("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx9(Close, {}) })
4544
+ /* @__PURE__ */ jsxs8("div", { className: "popover-heading", children: [
4545
+ /* @__PURE__ */ jsx11("h2", { children: "Settings" }),
4546
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx11(Close, {}) })
3943
4547
  ] }),
3944
- /* @__PURE__ */ jsxs7("fieldset", { className: "playback-options", children: [
3945
- /* @__PURE__ */ jsx9("legend", { children: "Video source" }),
3946
- visualModes.filter((mode) => chat.availableModes.includes(mode.id)).map((mode) => /* @__PURE__ */ jsxs7("label", { className: "switch-row", children: [
3947
- /* @__PURE__ */ jsxs7("span", { children: [
3948
- /* @__PURE__ */ jsx9("strong", { children: mode.label }),
3949
- /* @__PURE__ */ jsx9("small", { children: mode.note })
4548
+ /* @__PURE__ */ jsxs8("fieldset", { className: "playback-options", children: [
4549
+ /* @__PURE__ */ jsx11("legend", { children: "Video source" }),
4550
+ visualModes.filter((mode) => chat.availableModes.includes(mode.id)).map((mode) => /* @__PURE__ */ jsxs8("label", { className: "switch-row", children: [
4551
+ /* @__PURE__ */ jsxs8("span", { children: [
4552
+ /* @__PURE__ */ jsx11("strong", { children: mode.label }),
4553
+ /* @__PURE__ */ jsx11("small", { children: mode.note })
3950
4554
  ] }),
3951
- /* @__PURE__ */ jsx9(
4555
+ /* @__PURE__ */ jsx11(
3952
4556
  "input",
3953
4557
  {
3954
4558
  type: "radio",
@@ -3960,51 +4564,51 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3960
4564
  )
3961
4565
  ] }, mode.id))
3962
4566
  ] }),
3963
- /* @__PURE__ */ jsxs7("fieldset", { className: "playback-options", children: [
3964
- /* @__PURE__ */ jsx9("legend", { children: "Watching" }),
3965
- /* @__PURE__ */ jsxs7("label", { className: "switch-row", children: [
3966
- /* @__PURE__ */ jsxs7("span", { children: [
3967
- /* @__PURE__ */ jsx9("strong", { children: "Subtitles" }),
3968
- /* @__PURE__ */ jsx9("small", { children: "Read along with the answer" })
4567
+ /* @__PURE__ */ jsxs8("fieldset", { className: "playback-options", children: [
4568
+ /* @__PURE__ */ jsx11("legend", { children: "Watching" }),
4569
+ /* @__PURE__ */ jsxs8("label", { className: "switch-row", children: [
4570
+ /* @__PURE__ */ jsxs8("span", { children: [
4571
+ /* @__PURE__ */ jsx11("strong", { children: "Subtitles" }),
4572
+ /* @__PURE__ */ jsx11("small", { children: "Read along with the answer" })
3969
4573
  ] }),
3970
- /* @__PURE__ */ jsx9("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
4574
+ /* @__PURE__ */ jsx11("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3971
4575
  setCaptionsOn(event.target.checked);
3972
4576
  setCaptionsExpanded(false);
3973
4577
  } })
3974
4578
  ] }),
3975
- /* @__PURE__ */ jsxs7("label", { className: "switch-row", children: [
3976
- /* @__PURE__ */ jsxs7("span", { children: [
3977
- /* @__PURE__ */ jsx9("strong", { children: "Keep controls visible" }),
3978
- /* @__PURE__ */ jsx9("small", { children: "Keep the input bar on screen" })
4579
+ /* @__PURE__ */ jsxs8("label", { className: "switch-row", children: [
4580
+ /* @__PURE__ */ jsxs8("span", { children: [
4581
+ /* @__PURE__ */ jsx11("strong", { children: "Keep controls visible" }),
4582
+ /* @__PURE__ */ jsx11("small", { children: "Keep the input bar on screen" })
3979
4583
  ] }),
3980
- /* @__PURE__ */ jsx9("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
4584
+ /* @__PURE__ */ jsx11("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3981
4585
  ] })
3982
4586
  ] }),
3983
- /* @__PURE__ */ jsxs7("nav", { className: "developer-links", "aria-label": "Build with VanillaSky", children: [
3984
- /* @__PURE__ */ jsx9("p", { className: "section-label", children: "Build with VanillaSky" }),
3985
- /* @__PURE__ */ jsxs7("a", { href: "https://github.com/VanillaSkyAi/video/blob/main/docs/getting-started.md", target: "_blank", rel: "noopener noreferrer", children: [
4587
+ branding?.showDeveloperLinks !== false && /* @__PURE__ */ jsxs8("nav", { className: "developer-links", "aria-label": "Build with VanillaSky", children: [
4588
+ /* @__PURE__ */ jsx11("p", { className: "section-label", children: "Build with VanillaSky" }),
4589
+ /* @__PURE__ */ jsxs8("a", { href: "https://github.com/VanillaSkyAi/video/blob/main/docs/getting-started.md", target: "_blank", rel: "noopener noreferrer", children: [
3986
4590
  "Docs",
3987
- /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2197" })
4591
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: "\u2197" })
3988
4592
  ] }),
3989
- /* @__PURE__ */ jsxs7("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
4593
+ /* @__PURE__ */ jsxs8("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
3990
4594
  "About",
3991
- /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
4595
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3992
4596
  ] }),
3993
- /* @__PURE__ */ jsx9("div", { id: `${instanceId}-about`, className: "developer-about", role: "region", "aria-label": "About VanillaSky", hidden: !aboutOpen, children: /* @__PURE__ */ jsx9("p", { children: "VanillaSky is an open-source SDK for conversations that answer in video. Developers connect their own AI providers through their application server." }) }),
3994
- /* @__PURE__ */ jsxs7("a", { href: "https://github.com/VanillaSkyAi/video", target: "_blank", rel: "noopener noreferrer", children: [
4597
+ /* @__PURE__ */ jsx11("div", { id: `${instanceId}-about`, className: "developer-about", role: "region", "aria-label": "About VanillaSky", hidden: !aboutOpen, children: /* @__PURE__ */ jsx11("p", { children: "VanillaSky is an open-source SDK for conversations that answer in video. Developers connect their own AI providers through their application server." }) }),
4598
+ /* @__PURE__ */ jsxs8("a", { href: "https://github.com/VanillaSkyAi/video", target: "_blank", rel: "noopener noreferrer", children: [
3995
4599
  "GitHub",
3996
- /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2197" })
4600
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: "\u2197" })
3997
4601
  ] })
3998
4602
  ] })
3999
4603
  ]
4000
4604
  }
4001
4605
  )
4002
4606
  ] }),
4003
- /* @__PURE__ */ jsx9("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs7("div", { className: "panel-inner", children: [
4004
- /* @__PURE__ */ jsx9("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx9("div", { className: "caption-clip", children: chat.playbackEnded && !captionsExpanded ? /* @__PURE__ */ jsxs7("button", { type: "button", className: "transcript-toggle", "aria-expanded": false, onClick: () => setCaptionsExpanded(true), children: [
4607
+ /* @__PURE__ */ jsx11("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs8("div", { className: "panel-inner", children: [
4608
+ /* @__PURE__ */ jsx11("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx11("div", { className: "caption-clip", children: chat.playbackEnded && !captionsExpanded ? /* @__PURE__ */ jsxs8("button", { type: "button", className: "transcript-toggle", "aria-expanded": false, onClick: () => setCaptionsExpanded(true), children: [
4005
4609
  "Show transcript",
4006
- /* @__PURE__ */ jsx9(ChevronUp, {})
4007
- ] }) : /* @__PURE__ */ jsxs7(
4610
+ /* @__PURE__ */ jsx11(ChevronUp, {})
4611
+ ] }) : /* @__PURE__ */ jsxs8(
4008
4612
  "div",
4009
4613
  {
4010
4614
  className: "line-row",
@@ -4017,44 +4621,44 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
4017
4621
  onFocusCapture: captionControls.onFocusCapture,
4018
4622
  onBlurCapture: captionControls.onBlurCapture,
4019
4623
  children: [
4020
- captionsOn && line && /* @__PURE__ */ jsxs7("div", { className: "caption-actions", children: [
4021
- /* @__PURE__ */ jsxs7("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
4624
+ captionsOn && line && /* @__PURE__ */ jsxs8("div", { className: "caption-actions", children: [
4625
+ /* @__PURE__ */ jsxs8("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
4022
4626
  captionsExpanded ? "Collapse" : "Expand",
4023
- /* @__PURE__ */ jsx9(ChevronUp, {})
4627
+ /* @__PURE__ */ jsx11(ChevronUp, {})
4024
4628
  ] }),
4025
- /* @__PURE__ */ jsx9("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
4629
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
4026
4630
  setCaptionsOn(false);
4027
4631
  setCaptionsExpanded(false);
4028
- }, children: /* @__PURE__ */ jsx9(Close, {}) })
4632
+ }, children: /* @__PURE__ */ jsx11(Close, {}) })
4029
4633
  ] }),
4030
- captionsExpanded ? /* @__PURE__ */ jsx9("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx9("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx9(CaptionPages, { text: openingChapter && line === shown?.opening ? "" : line, getProgress: getCaptionProgress }, `${shown?.id}:${chat.playerKey}`)
4634
+ captionsExpanded ? /* @__PURE__ */ jsx11("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx11("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx11(CaptionPages, { text: openingChapter && line === shown?.opening ? "" : line, getProgress: getCaptionProgress }, `${shown?.id}:${chat.playerKey}`)
4031
4635
  ]
4032
4636
  }
4033
4637
  ) }) }),
4034
- showRecoveryNotice && chat.shownTurn && dismissedNoticeTurn !== chat.shownTurn.id && chat.warnings.includes(MEDIA_RECOVERY_NOTICE) && !chat.error && /* @__PURE__ */ jsxs7("div", { className: "recovery-notice", children: [
4035
- /* @__PURE__ */ jsx9("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
4036
- /* @__PURE__ */ jsx9("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx9(Close, {}) })
4638
+ showRecoveryNotice && chat.shownTurn && dismissedNoticeTurn !== chat.shownTurn.id && chat.warnings.includes(MEDIA_RECOVERY_NOTICE) && !chat.error && /* @__PURE__ */ jsxs8("div", { className: "recovery-notice", children: [
4639
+ /* @__PURE__ */ jsx11("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
4640
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx11(Close, {}) })
4037
4641
  ] }),
4038
- (chat.error || listen.error) && /* @__PURE__ */ jsxs7("p", { className: "error", role: "status", children: [
4039
- /* @__PURE__ */ jsx9(Warning, {}),
4040
- /* @__PURE__ */ jsx9("span", { children: chat.error?.message ?? listen.error })
4642
+ (chat.error || listen.error) && /* @__PURE__ */ jsxs8("p", { className: "error", role: "status", children: [
4643
+ /* @__PURE__ */ jsx11(Warning, {}),
4644
+ /* @__PURE__ */ jsx11("span", { children: chat.error?.message ?? listen.error })
4041
4645
  ] }),
4042
- /* @__PURE__ */ jsxs7("div", { ref: composerRef, className: "conversation-composer", "data-editing": editing, ...controlEvents, children: [
4043
- (listen.listening || listen.thinking) && /* @__PURE__ */ jsx9("div", { className: "composer-meta", children: /* @__PURE__ */ jsx9("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
4044
- /* @__PURE__ */ jsxs7("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
4646
+ /* @__PURE__ */ jsxs8("div", { ref: composerRef, className: "conversation-composer", "data-editing": editing, ...controlEvents, children: [
4647
+ (listen.listening || listen.thinking) && /* @__PURE__ */ jsx11("div", { className: "composer-meta", children: /* @__PURE__ */ jsx11("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
4648
+ /* @__PURE__ */ jsxs8("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
4045
4649
  event.preventDefault();
4046
4650
  ask(draft);
4047
4651
  }, children: [
4048
- transport && /* @__PURE__ */ jsx9("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
4652
+ transport && /* @__PURE__ */ jsx11("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
4049
4653
  listen.stop();
4050
4654
  setEditing(false);
4051
4655
  resumeAfterInput.current = false;
4052
4656
  inputRef.current?.blur();
4053
4657
  transport.action();
4054
4658
  }, children: transport.icon }),
4055
- /* @__PURE__ */ jsx9(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
4056
- /* @__PURE__ */ jsx9("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
4057
- /* @__PURE__ */ jsx9(
4659
+ /* @__PURE__ */ jsx11(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
4660
+ /* @__PURE__ */ jsx11("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
4661
+ /* @__PURE__ */ jsx11(
4058
4662
  "textarea",
4059
4663
  {
4060
4664
  id: promptId,
@@ -4082,17 +4686,28 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
4082
4686
  }
4083
4687
  }
4084
4688
  ),
4085
- listen.supported && /* @__PURE__ */ jsx9("button", { type: "button", className: `ghost${listen.listening ? " listening" : ""}`, "aria-label": listen.listening ? "Stop listening" : "Ask by voice", "aria-pressed": listen.listening, disabled: listen.thinking, onClick: () => beginInput(true), children: /* @__PURE__ */ jsx9(Mic, {}) }),
4086
- /* @__PURE__ */ jsx9("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx9(Send, {}) }),
4087
- editing && /* @__PURE__ */ jsx9("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx9(Close, {}) })
4689
+ listen.supported && /* @__PURE__ */ jsx11("button", { type: "button", className: `ghost${listen.listening ? " listening" : ""}`, "aria-label": listen.listening ? "Stop listening" : "Ask by voice", "aria-pressed": listen.listening, disabled: listen.thinking, onClick: () => beginInput(true), children: /* @__PURE__ */ jsx11(Mic, {}) }),
4690
+ /* @__PURE__ */ jsx11("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx11(Send, {}) }),
4691
+ editing && /* @__PURE__ */ jsx11("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx11(Close, {}) })
4088
4692
  ] })
4089
4693
  ] }),
4090
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx9("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
4694
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx11("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
4091
4695
  ] }) })
4092
4696
  ]
4093
4697
  }
4094
4698
  );
4095
4699
  }
4700
+ function safeHomeUrl(value) {
4701
+ const candidate = value?.trim();
4702
+ if (!candidate || !/^(?:https?:\/\/|\/(?![/\\]))/i.test(candidate)) return void 0;
4703
+ try {
4704
+ const url = new URL(candidate, "https://vanillasky.local");
4705
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password) return void 0;
4706
+ return candidate.startsWith("/") ? `${url.pathname}${url.search}${url.hash}` : url.href;
4707
+ } catch {
4708
+ return void 0;
4709
+ }
4710
+ }
4096
4711
  export {
4097
4712
  VideoChat,
4098
4713
  VideoError,