@vanillaskyai/video 0.10.23 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/PUBLIC-API.md +45 -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-PDQFQIQW.js → chunk-3EQ6PVWL.js} +1 -1
  7. package/dist/chunk-4G4JBMCM.js +37 -0
  8. package/dist/{chunk-7AA2JWHZ.js → chunk-5DOQTIMD.js} +6 -6
  9. package/dist/chunk-666HGTVZ.js +41 -0
  10. package/dist/{chunk-7M56IUUX.js → chunk-6Z3ID54H.js} +0 -18
  11. package/dist/{chunk-5BSJLT6H.js → chunk-E7FHBLAC.js} +16 -29
  12. package/dist/chunk-HIQM4J3U.js +30 -0
  13. package/dist/chunk-ISWQ6T5X.js +36 -0
  14. package/dist/{chunk-224QNWRA.js → chunk-K5J7ESRO.js} +1 -10
  15. package/dist/chunk-KMVRBUL5.js +50 -0
  16. package/dist/chunk-NM4CXXZY.js +25 -0
  17. package/dist/{chunk-44WND2VP.js → chunk-V2CP7PVY.js} +19 -63
  18. package/dist/{chunk-3O7OMMMF.js → chunk-WZESNEPT.js} +0 -1
  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-H2YUQDUY.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 +1350 -739
  29. package/dist/scene-validation-SGLLLFY5.js +9 -0
  30. package/dist/server.d.ts +67 -117
  31. package/dist/server.js +262 -614
  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 +134 -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";
11
+ import {
12
+ resolveMediaType
13
+ } from "./chunk-K5J7ESRO.js";
5
14
  import {
6
15
  getSceneDuration,
7
- getSceneDurationBounds
8
- } from "./chunk-ZD2VTUYR.js";
16
+ getSceneDurationBounds,
17
+ getVideoDuration,
18
+ resolveVideoTimeline
19
+ } from "./chunk-KMVRBUL5.js";
9
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-V2CP7PVY.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-WZESNEPT.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
- });
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" };
83
213
  }
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
- ]);
92
- }
93
-
94
- // src/visual-system/catalog/builtin-player.ts
95
- import { createElement } from "react";
96
214
 
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,35 @@ 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 (cued && !cued.scene.narrationGroup && narrationTime === void 0) {
1142
+ let speaking = false;
1143
+ try {
1144
+ speaking = callbacksRef.current.narrationActive?.(cued.scene) === true;
1145
+ } catch (cause) {
1146
+ failNarration(cause instanceof Error ? cause : new Error("Narration completion failed"), current);
1147
+ return;
1148
+ }
1149
+ if (completionHold?.sceneId !== cued.scene.id) completionHold = void 0;
1150
+ if (raw >= cued.end - CLIP_NARRATION_TAIL_SEC && speaking) {
1151
+ completionHold ??= { sceneId: cued.scene.id, wait: 0, tail: 0 };
1152
+ if (raw >= cued.end) completionHold.wait += elapsed;
1153
+ if (completionHold.wait >= 8) {
1154
+ failNarration(new Error("Narration did not finish within eight seconds of its scene budget"), current);
1155
+ return;
1156
+ }
1157
+ if (raw >= cued.end) {
1158
+ raw = Math.max(cued.start, cued.end - 0.01);
1159
+ completionBlocked = true;
1160
+ }
1161
+ } else if (completionHold && !speaking) {
1162
+ completionHold.tail += elapsed;
1163
+ if (completionHold.tail < CLIP_NARRATION_TAIL_SEC && raw >= cued.end) {
1164
+ raw = Math.max(cued.start, cued.end - 0.01);
1165
+ completionBlocked = true;
1166
+ } else if (completionHold.tail >= CLIP_NARRATION_TAIL_SEC) completionHold = void 0;
1167
+ }
1168
+ }
620
1169
  let nextTime;
621
1170
  if (looping && duration2 > 0 && raw >= duration2) {
622
1171
  nextTime = raw % duration2;
@@ -667,7 +1216,8 @@ function usePlaybackClock({
667
1216
  }
668
1217
  const duration = current.config ? getVideoDuration(current.config) : 0;
669
1218
  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);
1219
+ 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;
1220
+ reportStall(Boolean(reason), reason);
671
1221
  committedTime = timeRef.current;
672
1222
  if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
673
1223
  else setIsPlaying(false);
@@ -681,7 +1231,7 @@ function usePlaybackClock({
681
1231
  }
682
1232
 
683
1233
  // src/player/video-player.tsx
684
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1234
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
685
1235
  var MINIMUM_GENERATION_INTRO_MS = 3e3;
686
1236
  function savedVideoState(video) {
687
1237
  return {
@@ -691,7 +1241,6 @@ function savedVideoState(video) {
691
1241
  };
692
1242
  }
693
1243
  function VideoPlayerRuntime({
694
- kit,
695
1244
  stream,
696
1245
  video,
697
1246
  playbackMode,
@@ -713,6 +1262,8 @@ function VideoPlayerRuntime({
713
1262
  onSceneChange,
714
1263
  narrationReady,
715
1264
  narrationTime,
1265
+ narrationActive,
1266
+ onPlaybackMetric,
716
1267
  onFramePresented,
717
1268
  onMediaFramePresented,
718
1269
  onStallChange,
@@ -722,10 +1273,10 @@ function VideoPlayerRuntime({
722
1273
  1,
723
1274
  typeof nativeMediaAudio?.volume === "number" && Number.isFinite(nativeMediaAudio.volume) ? nativeMediaAudio.volume : 1
724
1275
  ));
725
- const [reducedMotion, setReducedMotion] = useState(
1276
+ const [reducedMotion, setReducedMotion] = useState2(
726
1277
  () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
727
1278
  );
728
- const [audioUnlocked, setAudioUnlocked] = useState(false);
1279
+ const [audioUnlocked, setAudioUnlocked] = useState2(false);
729
1280
  const { resolvedStartMuted, shouldAutoPlay, autoStartGeneration } = resolvePlaybackPolicy({
730
1281
  playbackMode,
731
1282
  autoPlay,
@@ -734,28 +1285,40 @@ function VideoPlayerRuntime({
734
1285
  reducedMotion,
735
1286
  hasStream: Boolean(stream)
736
1287
  });
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);
1288
+ const [isPlaying, setIsPlaying] = useState2(() => shouldAutoPlay && !reducedMotion && !autoStartGeneration);
1289
+ const [isMuted, setIsMuted] = useState2(resolvedStartMuted);
1290
+ const [fullscreenMode, setFullscreenMode] = useState2("none");
1291
+ const [state, setState] = useState2(() => video ? savedVideoState(video) : createVideoState());
1292
+ const [currentTime, setCurrentTime] = useState2(0);
1293
+ const savedActiveIndex = video ? resolveVideoTimeline(video).findIndex((range) => currentTime >= range.start && currentTime < range.end) : -1;
1294
+ const savedPreparationIndex = savedActiveIndex >= 0 ? savedActiveIndex : Math.max(0, (video?.scenes.length ?? 1) - 1);
1295
+ useEffect5(() => {
1296
+ if (!video) return;
1297
+ const controller = new AbortController();
1298
+ for (const scene of video.scenes.slice(savedPreparationIndex, savedPreparationIndex + 2)) {
1299
+ preloadBuiltinTemplate(scene.templateId);
1300
+ warmSceneMedia(scene.variables, controller.signal);
1301
+ }
1302
+ return () => controller.abort();
1303
+ }, [video, savedPreparationIndex]);
1304
+ const [activeStream, setActiveStream] = useState2(stream);
1305
+ const [activeSavedVideo, setActiveSavedVideo] = useState2(video);
1306
+ const [replacementPending, setReplacementPending] = useState2(false);
1307
+ const [startRequested, setStartRequested] = useState2(autoStartGeneration);
1308
+ const [introPlaying, setIntroPlaying] = useState2(autoStartGeneration);
1309
+ const [generationIntroComplete, setGenerationIntroComplete] = useState2(() => !playbackMode || !stream);
1310
+ const [observedWidth, setObservedWidth] = useState2(0);
1311
+ const containerRef = useRef4(null);
1312
+ usePlaybackDiagnostics(containerRef, state.config ?? void 0, onPlaybackMetric);
1313
+ const stateRef = useRef4(state);
1314
+ const timeRef = useRef4(currentTime);
1315
+ const audioRef = useRef4(null);
1316
+ const introStartedAtRef = useRef4(autoStartGeneration ? performance.now() : null);
1317
+ const callbacksRef = useRef4({ onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, narrationActive, onFramePresented, onMediaFramePresented, onStallChange, onStateChange });
1318
+ const loopRef = useRef4(loop);
1319
+ const sceneIndexRef = useRef4(-1);
1320
+ const mediaFrameReportedRef = useRef4(false);
1321
+ const visualReadyRef = useRef4(void 0);
759
1322
  const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false) => {
760
1323
  if (error) {
761
1324
  setIsPlaying(false);
@@ -771,7 +1334,7 @@ function VideoPlayerRuntime({
771
1334
  }
772
1335
  }
773
1336
  }, []);
774
- const playbackEndedRef = useRef(false);
1337
+ const playbackEndedRef = useRef4(false);
775
1338
  let mediaPlaying = isPlaying;
776
1339
  if (sceneIndexRef.current >= 0) {
777
1340
  try {
@@ -782,7 +1345,7 @@ function VideoPlayerRuntime({
782
1345
  }
783
1346
  stateRef.current = state;
784
1347
  timeRef.current = currentTime;
785
- callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange };
1348
+ callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, narrationActive, onFramePresented, onMediaFramePresented, onStallChange, onStateChange };
786
1349
  loopRef.current = loop;
787
1350
  const reportFramePresented = useMemo(() => {
788
1351
  let reported = false;
@@ -827,7 +1390,7 @@ function VideoPlayerRuntime({
827
1390
  setGenerationIntroComplete(!playbackMode || !stream);
828
1391
  introStartedAtRef.current = autoStartReplacement ? performance.now() : null;
829
1392
  }
830
- useEffect2(() => {
1393
+ useEffect5(() => {
831
1394
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
832
1395
  const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
833
1396
  const handleChange = (event) => {
@@ -842,12 +1405,12 @@ function VideoPlayerRuntime({
842
1405
  preference.addEventListener?.("change", handleChange);
843
1406
  return () => preference.removeEventListener?.("change", handleChange);
844
1407
  }, []);
845
- useEffect2(() => {
1408
+ useEffect5(() => {
846
1409
  import("./control-visibility-NQOWTKOQ.js");
847
1410
  }, []);
848
- useEffect2(() => setIsMuted(resolvedStartMuted), [resolvedStartMuted]);
849
- const pausedRef = useRef(paused);
850
- useEffect2(() => {
1411
+ useEffect5(() => setIsMuted(resolvedStartMuted), [resolvedStartMuted]);
1412
+ const pausedRef = useRef4(paused);
1413
+ useEffect5(() => {
851
1414
  const previous = pausedRef.current;
852
1415
  pausedRef.current = paused;
853
1416
  if (paused === void 0 || paused === previous) return;
@@ -863,7 +1426,7 @@ function VideoPlayerRuntime({
863
1426
  const audio = audioRef.current;
864
1427
  if (audio?.paused) void audio.play().catch(Boolean);
865
1428
  }, [paused]);
866
- useEffect2(() => {
1429
+ useEffect5(() => {
867
1430
  let cancelled = false;
868
1431
  let started = false;
869
1432
  const reset = video ? savedVideoState(video) : createVideoState();
@@ -871,13 +1434,7 @@ function VideoPlayerRuntime({
871
1434
  timeRef.current = 0;
872
1435
  setState(reset);
873
1436
  setCurrentTime(0);
874
- if (video) {
875
- for (const scene of video.scenes) {
876
- preloadBuiltinTemplate(scene.templateId);
877
- warmSceneMedia(scene.variables);
878
- }
879
- return;
880
- }
1437
+ if (video) return;
881
1438
  if (!stream) return;
882
1439
  const consume = async () => {
883
1440
  try {
@@ -913,7 +1470,7 @@ function VideoPlayerRuntime({
913
1470
  }
914
1471
  };
915
1472
  }, [stream, video]);
916
- useEffect2(() => {
1473
+ useEffect5(() => {
917
1474
  if (width != null) return;
918
1475
  const container = containerRef.current;
919
1476
  if (!container) return;
@@ -938,12 +1495,12 @@ function VideoPlayerRuntime({
938
1495
  setCurrentTime,
939
1496
  setIsPlaying
940
1497
  });
941
- useEffect2(() => {
1498
+ useEffect5(() => {
942
1499
  const audio = audioRef.current;
943
1500
  if (!audio) return;
944
1501
  audio.volume = state.config?.audio?.volume ?? 1;
945
1502
  }, [state.config?.audio?.audioUrl, state.config?.audio?.volume]);
946
- useEffect2(() => {
1503
+ useEffect5(() => {
947
1504
  const audio = audioRef.current;
948
1505
  if (!audio) return;
949
1506
  if (isPlaying || introPlaying) {
@@ -961,7 +1518,7 @@ function VideoPlayerRuntime({
961
1518
  audio.pause();
962
1519
  }
963
1520
  }, [introPlaying, isPlaying, state.config?.audio?.audioUrl]);
964
- useEffect2(() => {
1521
+ useEffect5(() => {
965
1522
  if (!state.config?.scenes.length) {
966
1523
  const terminalWithoutVideo = state.status === "complete" || state.status === "error" || state.status === "aborted";
967
1524
  if (terminalWithoutVideo) {
@@ -1019,7 +1576,7 @@ function VideoPlayerRuntime({
1019
1576
  const terminal = state.status === "complete" || state.status === "error" || state.status === "aborted";
1020
1577
  const playheadAtEnd = terminal && duration > 0 && currentTime >= duration - 1e-3;
1021
1578
  const ended = !loop && playheadAtEnd;
1022
- useEffect2(() => {
1579
+ useEffect5(() => {
1023
1580
  if (!playheadAtEnd) {
1024
1581
  playbackEndedRef.current = false;
1025
1582
  return;
@@ -1032,7 +1589,7 @@ function VideoPlayerRuntime({
1032
1589
  const firstSceneRange = config ? resolveVideoTimeline(config)[0] : void 0;
1033
1590
  const hasSuppliedOpening = firstSceneRange?.scene.id === "supplied-opening";
1034
1591
  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;
1592
+ const firstSceneHoldProgress = firstSceneRange ? 0.7 : 0;
1036
1593
  const posterTime = firstSceneRange ? firstSceneRange.start + Math.max(0, firstSceneRange.end - firstSceneRange.start) * firstSceneHoldProgress : currentTime;
1037
1594
  const startPlayback = () => {
1038
1595
  setGenerationIntroComplete(true);
@@ -1095,7 +1652,7 @@ function VideoPlayerRuntime({
1095
1652
  const pendingReplacement = replacementPending && state.status === "idle";
1096
1653
  const displayedStatus = pendingReplacement ? "streaming" : state.status;
1097
1654
  const generationCoverVisible = generationIntroWaiting && !hasSuppliedOpening || displayedStatus === "streaming" && !config?.scenes.length;
1098
- return /* @__PURE__ */ jsxs2(
1655
+ return /* @__PURE__ */ jsxs3(
1099
1656
  "div",
1100
1657
  {
1101
1658
  ref: containerRef,
@@ -1135,7 +1692,7 @@ function VideoPlayerRuntime({
1135
1692
  ...style
1136
1693
  },
1137
1694
  children: [
1138
- /* @__PURE__ */ jsx2(
1695
+ /* @__PURE__ */ jsx4(
1139
1696
  GenerationCover,
1140
1697
  {
1141
1698
  visible: generationCoverVisible,
@@ -1146,10 +1703,9 @@ function VideoPlayerRuntime({
1146
1703
  onStart: armPlayback
1147
1704
  }
1148
1705
  ),
1149
- !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx2(MountedReadinessContext.Provider, { value: reportVisualReady, children: /* @__PURE__ */ jsx2(
1706
+ !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx4(MountedReadinessContext.Provider, { value: reportVisualReady, children: /* @__PURE__ */ jsx4(
1150
1707
  VideoFrame,
1151
1708
  {
1152
- kit,
1153
1709
  onFramePresented: !showStartPoster && onFramePresented ? reportFramePresented : void 0,
1154
1710
  config: displayConfig,
1155
1711
  time: showStartPoster ? posterTime : currentTime,
@@ -1168,7 +1724,7 @@ function VideoPlayerRuntime({
1168
1724
  }
1169
1725
  }
1170
1726
  ) }) : null,
1171
- /* @__PURE__ */ jsx2(
1727
+ /* @__PURE__ */ jsx4(
1172
1728
  StartPosterButton,
1173
1729
  {
1174
1730
  visible: showStartPoster,
@@ -1177,7 +1733,7 @@ function VideoPlayerRuntime({
1177
1733
  onStart: startPlayback
1178
1734
  }
1179
1735
  ),
1180
- config?.audio ? /* @__PURE__ */ jsx2(
1736
+ config?.audio ? /* @__PURE__ */ jsx4(
1181
1737
  "audio",
1182
1738
  {
1183
1739
  ref: audioRef,
@@ -1190,8 +1746,8 @@ function VideoPlayerRuntime({
1190
1746
  },
1191
1747
  config.audio.audioUrl
1192
1748
  ) : null,
1193
- /* @__PURE__ */ jsx2(EndedOverlay, { ended: controls && ended, onReplay: togglePlayback }),
1194
- /* @__PURE__ */ jsx2(
1749
+ /* @__PURE__ */ jsx4(EndedOverlay, { ended: controls && ended, onReplay: togglePlayback }),
1750
+ /* @__PURE__ */ jsx4(
1195
1751
  PlayerControls,
1196
1752
  {
1197
1753
  visible: controls && !generationCoverVisible && !showStartPoster && Boolean(config?.scenes.length),
@@ -1214,7 +1770,6 @@ function VideoPlayerRuntime({
1214
1770
  );
1215
1771
  }
1216
1772
  function VideoPlayer({
1217
- templates,
1218
1773
  stream,
1219
1774
  video,
1220
1775
  onPlaybackEnd,
@@ -1223,16 +1778,11 @@ function VideoPlayer({
1223
1778
  ...props
1224
1779
  }) {
1225
1780
  const savedVideo = useMemo(() => video ? parseVideo(video) : void 0, [video]);
1226
- const kit = useMemo(
1227
- () => overlayPlayerTemplateRegistry(BUILTIN_PLAYER_KIT, templates),
1228
- [templates]
1229
- );
1230
1781
  if (!stream && !savedVideo) return null;
1231
- return /* @__PURE__ */ jsx2(
1782
+ return /* @__PURE__ */ jsx4(
1232
1783
  VideoPlayerRuntime,
1233
1784
  {
1234
1785
  ...props,
1235
- kit,
1236
1786
  stream,
1237
1787
  video: savedVideo,
1238
1788
  onPlaybackEnd: (state) => {
@@ -1335,21 +1885,144 @@ function createCaptionVoice(source, now = () => performance.now()) {
1335
1885
  return { voice, getCaptionProgress: read, reset };
1336
1886
  }
1337
1887
 
1888
+ // src/player/prepare-scene-media.ts
1889
+ async function prepareSceneMedia(variables, signal) {
1890
+ signal.throwIfAborted();
1891
+ const url = typeof variables.mediaUrl === "string" ? variables.mediaUrl.trim() : "";
1892
+ if (!url || variables.mediaType === "gradient") return "graphic";
1893
+ const video = variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(url);
1894
+ if (video) return "awaiting-video-frame";
1895
+ await new Promise((resolve, reject) => {
1896
+ const image = new Image();
1897
+ let settled = false;
1898
+ const finish = (error) => {
1899
+ if (settled) return;
1900
+ settled = true;
1901
+ clearTimeout(timer);
1902
+ signal.removeEventListener("abort", abort);
1903
+ image.onload = null;
1904
+ image.onerror = null;
1905
+ if (error) {
1906
+ image.src = "";
1907
+ reject(error);
1908
+ } else resolve();
1909
+ };
1910
+ const abort = () => finish(signal.reason ?? new DOMException("Cancelled", "AbortError"));
1911
+ const timer = setTimeout(() => finish(new DOMException("Media preparation timed out", "TimeoutError")), 8e3);
1912
+ signal.addEventListener("abort", abort, { once: true });
1913
+ image.onload = () => {
1914
+ void (image.decode ? image.decode() : Promise.resolve()).then(() => finish(), () => finish(new Error("Could not prepare scene image")));
1915
+ };
1916
+ image.onerror = () => finish(new Error("Could not prepare scene image"));
1917
+ image.src = url;
1918
+ if (image.complete && image.naturalWidth > 0) image.onload(new Event("load"));
1919
+ });
1920
+ return "image";
1921
+ }
1922
+
1338
1923
  // src/player/recover-scene-media.ts
1339
1924
  function recoverSceneMedia(scene) {
1340
1925
  if (scene.templateId === "cinemaMedia") {
1341
1926
  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() } };
1927
+ const fallback = typeof title === "string" && title.trim() && [...title].length <= 65 ? title.trim() : "Your response continues.";
1928
+ return { ...scene, templateId: "chapterTitle", variables: { title: fallback } };
1344
1929
  }
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 };
1930
+ return void 0;
1931
+ }
1932
+
1933
+ // src/player/scene-readiness.ts
1934
+ function preparedSceneDuration(scene, spokenSeconds, metadata) {
1935
+ const timing = metadata?.timing;
1936
+ const authored = (timing?.revealSeconds ?? 0) + (timing?.holdSeconds ?? 0) + (timing?.exitSeconds ?? 0);
1937
+ const readable = Math.max(getSceneDurationBounds(scene, metadata).readable, authored);
1938
+ return Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? Math.max(readable, spokenSeconds + CLIP_NARRATION_TAIL_SEC) : Math.max(readable, getSceneDuration(scene, metadata));
1939
+ }
1940
+ function prepareNarratedScene(scene, spokenSeconds) {
1941
+ const requested = scene.timing.fixedDuration;
1942
+ const actual = scene.variables.mediaDurationSec;
1943
+ const durations = [requested, actual].filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0);
1944
+ const clipDurationSec = scene.templateId === "cinemaMedia" && durations.length ? Math.min(...durations) : void 0;
1945
+ const recovered = clipDurationSec !== void 0 && spokenSeconds !== void 0 && !speechFitsClip(spokenSeconds, clipDurationSec);
1946
+ const visual = recovered ? recoverSceneMedia(scene) : scene;
1947
+ const duration = preparedSceneDuration(visual, spokenSeconds, getBuiltinSceneDefinition(visual.templateId));
1948
+ const { startTime: _start, endTime: _end, beatStart: _beatStart, beatEnd: _beatEnd, ...timing } = visual.timing;
1949
+ return { scene: { ...visual, timing: { ...timing, fixedDuration: duration } }, recovered, clipDurationSec };
1950
+ }
1951
+
1952
+ // src/video-chat/scene-preparation.ts
1953
+ function createScenePreparation(options) {
1954
+ const speechLoads = /* @__PURE__ */ new Map();
1955
+ const lanes = [Promise.resolve(), Promise.resolve()];
1956
+ const announced = /* @__PURE__ */ new Set();
1957
+ const announcedMedia = /* @__PURE__ */ new Set();
1958
+ let nextLane = 0;
1959
+ let customVoiceFailed = false;
1960
+ const prepareSpeech = (text, signal = options.signal) => {
1961
+ const cached = speechLoads.get(text);
1962
+ if (cached) return cached;
1963
+ const lane = nextLane++ % lanes.length;
1964
+ const prepared = lanes[lane].then(async () => {
1965
+ signal.throwIfAborted();
1966
+ try {
1967
+ if (customVoiceFailed) throw new Error("Voice preparation did not respond");
1968
+ return options.customVoice ? await withDeadline((child) => options.voice().prepare(text, { signal: child }), 3e3, signal) : await options.voice().prepare(text, { signal });
1969
+ } catch (cause) {
1970
+ if (options.customVoice && !signal.aborted && !options.signal.aborted) {
1971
+ options.onVoiceUnavailable(text);
1972
+ if (cause instanceof DOMException && cause.name === "TimeoutError") customVoiceFailed = true;
1973
+ }
1974
+ throw cause;
1975
+ }
1976
+ });
1977
+ speechLoads.set(text, prepared);
1978
+ lanes[lane] = prepared.catch(() => void 0);
1979
+ return prepared;
1980
+ };
1981
+ const announce = (value) => {
1982
+ if (options.signal.aborted || !value || typeof value !== "object" || Array.isArray(value)) return;
1983
+ const data = value;
1984
+ 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;
1985
+ announced.add(data.sceneId);
1986
+ if (speechLoads.size < 32 || speechLoads.has(data.narration.trim())) void prepareSpeech(data.narration.trim()).catch(() => void 0);
1987
+ const media = sanitizeVideoChatMedia(data.media);
1988
+ if (media && !announcedMedia.has(data.sceneId)) {
1989
+ announcedMedia.add(data.sceneId);
1990
+ warmSceneMedia({ mediaUrl: media.url, mediaType: media.type === "image" ? "photo" : "video", mediaPoster: media.posterUrl }, options.signal);
1991
+ }
1992
+ };
1993
+ const prepareVisual = (scene) => {
1994
+ warmSceneMedia(scene.variables, options.signal);
1995
+ const prepared = Promise.resolve(preloadBuiltinTemplate(scene.templateId)).then(async () => {
1996
+ try {
1997
+ await prepareSceneMedia(scene.variables, options.signal);
1998
+ return scene;
1999
+ } catch (cause) {
2000
+ if (options.signal.aborted) throw cause;
2001
+ const recovered = recoverSceneMedia(scene);
2002
+ if (!recovered) throw new VideoError("Scene could not prepare its visual", { code: "media_not_ready" });
2003
+ options.warn(MEDIA_RECOVERY_NOTICE);
2004
+ return recovered;
2005
+ }
2006
+ });
2007
+ void prepared.catch(() => void 0);
2008
+ return prepared;
2009
+ };
2010
+ const pace = (scene, seconds) => {
2011
+ const prepared = prepareNarratedScene(scene, seconds);
2012
+ if (prepared.recovered) options.warn(MEDIA_RECOVERY_NOTICE);
2013
+ if (seconds !== void 0 && prepared.clipDurationSec !== void 0) {
2014
+ try {
2015
+ options.onDuration?.({ speechDurationSec: seconds, clipDurationSec: prepared.clipDurationSec, recovered: prepared.recovered });
2016
+ } catch {
2017
+ }
2018
+ }
2019
+ return prepared.scene;
2020
+ };
2021
+ return { prepareSpeech, announce, prepareVisual, pace };
1349
2022
  }
1350
2023
 
1351
2024
  // src/video-chat/use-video-chat.ts
1352
- import { useCallback as useCallback2, useEffect as useEffect4, useMemo as useMemo2, useReducer, useRef as useRef3 } from "react";
2025
+ import { useCallback as useCallback3, useEffect as useEffect7, useMemo as useMemo2, useReducer, useRef as useRef6 } from "react";
1353
2026
 
1354
2027
  // src/protocol/scene-timeline.ts
1355
2028
  function createQueue() {
@@ -1457,25 +2130,17 @@ function createSceneTimeline(options) {
1457
2130
  };
1458
2131
  }
1459
2132
 
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
2133
  // src/player/use-narration.ts
1469
- import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
2134
+ import { useCallback as useCallback2, useEffect as useEffect6, useRef as useRef5, useState as useState3 } from "react";
1470
2135
  function useNarration(options) {
1471
- const [speaking, setSpeaking] = useState2(false);
1472
- const optionsRef = useRef2(options);
2136
+ const [speaking, setSpeaking] = useState3(false);
2137
+ const optionsRef = useRef5(options);
1473
2138
  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) => {
2139
+ const groupRef = useRef5(void 0);
2140
+ const clockRef = useRef5(void 0);
2141
+ const clockSceneRef = useRef5(void 0);
2142
+ const readyRef = useRef5(true);
2143
+ const getTime = useCallback2((scene) => {
1479
2144
  const group = groupRef.current;
1480
2145
  if (optionsRef.current.enabled === false || (group ? scene.narrationGroup?.id !== group.id : scene.id !== clockSceneRef.current)) return void 0;
1481
2146
  if (!optionsRef.current.voice.getCurrentTime) return void 0;
@@ -1488,10 +2153,11 @@ function useNarration(options) {
1488
2153
  }
1489
2154
  return clockRef.current;
1490
2155
  }, []);
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(() => {
2156
+ const isReady = useCallback2(() => optionsRef.current.enabled === false || readyRef.current || clockRef.current !== void 0 && clockRef.current >= (groupRef.current?.offsetSeconds ?? 0) + 0.04, []);
2157
+ const currentRef = useRef5(void 0);
2158
+ const isSpeaking = useCallback2((scene) => optionsRef.current.enabled !== false && Boolean(currentRef.current) && (groupRef.current ? scene.narrationGroup?.id === groupRef.current.id : scene.id === clockSceneRef.current), []);
2159
+ const spokenIndexRef = useRef5(void 0);
2160
+ const stop = useCallback2(() => {
1495
2161
  readyRef.current = true;
1496
2162
  currentRef.current?.abort();
1497
2163
  currentRef.current = void 0;
@@ -1500,12 +2166,12 @@ function useNarration(options) {
1500
2166
  clockSceneRef.current = void 0;
1501
2167
  setSpeaking(false);
1502
2168
  }, []);
1503
- const interrupt = useCallback(() => {
2169
+ const interrupt = useCallback2(() => {
1504
2170
  spokenIndexRef.current = void 0;
1505
2171
  stop();
1506
2172
  }, [stop]);
1507
- useEffect3(() => () => currentRef.current?.abort(), []);
1508
- const onSceneChange = useCallback((scene, index) => {
2173
+ useEffect6(() => () => currentRef.current?.abort(), []);
2174
+ const onSceneChange = useCallback2((scene, index) => {
1509
2175
  const { voice, enabled = true } = optionsRef.current;
1510
2176
  if (!enabled) return;
1511
2177
  if (spokenIndexRef.current === index) return;
@@ -1565,42 +2231,7 @@ function useNarration(options) {
1565
2231
  }
1566
2232
  })();
1567
2233
  }, [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";
2234
+ return { onSceneChange, interrupt, speaking, isReady, getTime, isSpeaking };
1604
2235
  }
1605
2236
 
1606
2237
  // src/video-chat/voice.ts
@@ -1614,8 +2245,7 @@ function actionEndpoint(endpoint, action) {
1614
2245
  return `${value}${value.includes("?") ? "&" : "?"}action=${encodeURIComponent(action)}`;
1615
2246
  }
1616
2247
  function estimatedBrowserSeconds(text) {
1617
- const words = text.trim().split(/\s+/u).filter(Boolean).length;
1618
- return Math.max(1, words / 2.5);
2248
+ return Math.max(1, estimateNarrationSeconds(text));
1619
2249
  }
1620
2250
  async function measureSeconds(bytes) {
1621
2251
  try {
@@ -1950,7 +2580,7 @@ function createVideoChatVoice(options = {}) {
1950
2580
  }
1951
2581
 
1952
2582
  // src/video-chat/use-video-chat.ts
1953
- var DEFAULT_TIMEOUT_MS = 18e4;
2583
+ var DEFAULT_TIMEOUT_MS = 66e4;
1954
2584
  function observe(callback) {
1955
2585
  try {
1956
2586
  void Promise.resolve(callback?.()).catch(() => void 0);
@@ -2185,29 +2815,14 @@ async function responseError(response) {
2185
2815
  recoverable: false
2186
2816
  });
2187
2817
  }
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
2818
  function useVideoChat(options = {}) {
2204
2819
  return useVideoChatSession(options).chat;
2205
2820
  }
2206
2821
  function useVideoChatSession(options = {}) {
2207
- const optionsRef = useRef3(options);
2822
+ const optionsRef = useRef6(options);
2208
2823
  optionsRef.current = options;
2209
- const voiceWarningRef = useRef3(() => void 0);
2210
- const ownedVoiceRef = useRef3(void 0);
2824
+ const voiceWarningRef = useRef6(() => void 0);
2825
+ const ownedVoiceRef = useRef6(void 0);
2211
2826
  if (!options.voice && !ownedVoiceRef.current) {
2212
2827
  ownedVoiceRef.current = createVideoChatVoice({
2213
2828
  endpoint: options.endpoint,
@@ -2219,12 +2834,12 @@ function useVideoChatSession(options = {}) {
2219
2834
  }
2220
2835
  const rawVoice = options.voice ?? ownedVoiceRef.current;
2221
2836
  const captionVoice = useMemo2(() => createCaptionVoice(rawVoice), [rawVoice]);
2222
- useEffect4(() => () => captionVoice.reset(), [captionVoice]);
2837
+ useEffect7(() => () => captionVoice.reset(), [captionVoice]);
2223
2838
  const voice = captionVoice.voice;
2224
- const voiceRef = useRef3(voice);
2839
+ const voiceRef = useRef6(voice);
2225
2840
  voiceRef.current = voice;
2226
- const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
2227
- const speechStartRef = useRef3(() => void 0);
2841
+ const unavailableVoiceLines = useRef6(/* @__PURE__ */ new Set());
2842
+ const speechStartRef = useRef6(() => void 0);
2228
2843
  const narration = useNarration({ onSpeechStart: (source) => speechStartRef.current(source), voice: {
2229
2844
  supportsOffsets: voice.supportsOffsets,
2230
2845
  ...voice.getCurrentTime ? { getCurrentTime: () => voice.getCurrentTime() } : {},
@@ -2238,24 +2853,24 @@ function useVideoChatSession(options = {}) {
2238
2853
  }
2239
2854
  }
2240
2855
  } });
2241
- const narrationRef = useRef3(narration);
2856
+ const narrationRef = useRef6(narration);
2242
2857
  narrationRef.current = narration;
2243
2858
  const [state, dispatch] = useReducer(reducer, options.initialMuted ?? false, initialState);
2244
- const stateRef = useRef3(state);
2859
+ const stateRef = useRef6(state);
2245
2860
  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) => {
2861
+ const runRef = useRef6(0);
2862
+ const inFlightRef = useRef6(void 0);
2863
+ const suggestionsRef = useRef6(void 0);
2864
+ const openingRef = useRef6(void 0);
2865
+ const timelineRef = useRef6(void 0);
2866
+ const heldRef = useRef6(false);
2867
+ const flushRef = useRef6(void 0);
2868
+ const mountedRef = useRef6(true);
2869
+ const firstFrameRef = useRef6(void 0);
2870
+ const reportMetric = useCallback3((metric) => {
2256
2871
  observe(() => optionsRef.current.onPlaybackMetric?.(metric));
2257
2872
  }, []);
2258
- const finishStall = useCallback2(() => {
2873
+ const finishStall = useCallback3(() => {
2259
2874
  const timing = firstFrameRef.current;
2260
2875
  if (!timing?.active || timing.stallStartedAt == null) return;
2261
2876
  const now = monotonicNow();
@@ -2267,10 +2882,10 @@ function useVideoChatSession(options = {}) {
2267
2882
  mode: timing.mode,
2268
2883
  elapsedMs: Math.max(0, Math.round(now - timing.startedAt)),
2269
2884
  durationMs: Math.max(0, Math.round(now - started)),
2270
- reason: "scene-generation"
2885
+ reason: timing.stallReason ?? "scene-generation"
2271
2886
  });
2272
2887
  }, [reportMetric]);
2273
- const endTiming = useCallback2(() => {
2888
+ const endTiming = useCallback3(() => {
2274
2889
  finishStall();
2275
2890
  if (firstFrameRef.current) firstFrameRef.current.active = false;
2276
2891
  }, [finishStall]);
@@ -2286,7 +2901,7 @@ function useVideoChatSession(options = {}) {
2286
2901
  source: optionsRef.current.voice ? "custom" : source === "generated" ? "generated" : "browser"
2287
2902
  });
2288
2903
  };
2289
- const request = useCallback2(async (action, init = {}, signal) => {
2904
+ const request = useCallback3(async (action, init = {}, signal) => {
2290
2905
  const current = optionsRef.current;
2291
2906
  const headers = new Headers(current.headers);
2292
2907
  new Headers(init.headers).forEach((header, name) => headers.set(name, header));
@@ -2300,7 +2915,7 @@ function useVideoChatSession(options = {}) {
2300
2915
  signal
2301
2916
  });
2302
2917
  }, []);
2303
- useEffect4(() => {
2918
+ useEffect7(() => {
2304
2919
  mountedRef.current = true;
2305
2920
  const controller = new AbortController();
2306
2921
  void request("capabilities", {}, controller.signal).then(async (response) => {
@@ -2331,8 +2946,8 @@ function useVideoChatSession(options = {}) {
2331
2946
  });
2332
2947
  };
2333
2948
  }, [request, endTiming]);
2334
- useEffect4(() => voice.setMuted(state.muted), [state.muted, voice]);
2335
- const cancel = useCallback2((reason = "Video chat was cancelled") => {
2949
+ useEffect7(() => voice.setMuted(state.muted), [state.muted, voice]);
2950
+ const cancel = useCallback3((reason = "Video chat was cancelled") => {
2336
2951
  endTiming();
2337
2952
  runRef.current += 1;
2338
2953
  inFlightRef.current?.abort(new DOMException(reason, "AbortError"));
@@ -2346,7 +2961,7 @@ function useVideoChatSession(options = {}) {
2346
2961
  narrationRef.current.interrupt();
2347
2962
  dispatch({ type: "cancelled" });
2348
2963
  }, [endTiming]);
2349
- const ask = useCallback2(async (value, askOptions = {}) => {
2964
+ const ask = useCallback3(async (value, askOptions = {}) => {
2350
2965
  const prompt = value.trim();
2351
2966
  if (!prompt) return void 0;
2352
2967
  endTiming();
@@ -2456,31 +3071,26 @@ function useVideoChatSession(options = {}) {
2456
3071
  }
2457
3072
  };
2458
3073
  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
- };
3074
+ const preparation = createScenePreparation({
3075
+ voice: () => voiceRef.current,
3076
+ customVoice: Boolean(currentOptions.voice),
3077
+ signal: controller.signal,
3078
+ onVoiceUnavailable: (text) => {
3079
+ if (isCurrent()) unavailableVoiceLines.current.add(text);
3080
+ },
3081
+ warn,
3082
+ onDuration: (value2) => {
3083
+ const timing = firstFrameRef.current;
3084
+ if (isCurrent() && timing?.active) reportMetric({
3085
+ type: "scene-duration",
3086
+ ...value2,
3087
+ turnId: id,
3088
+ mode,
3089
+ elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt))
3090
+ });
3091
+ }
3092
+ });
3093
+ const { prepareSpeech } = preparation;
2484
3094
  const speakOpening = async (text) => {
2485
3095
  try {
2486
3096
  if (!isOpeningCurrent()) return;
@@ -2539,10 +3149,7 @@ function useVideoChatSession(options = {}) {
2539
3149
  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
3150
  }
2541
3151
  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
- }
3152
+ preparation.announce(event.data);
2546
3153
  continue;
2547
3154
  }
2548
3155
  if (event.type === "data.video-chat-opening") {
@@ -2572,22 +3179,7 @@ function useVideoChatSession(options = {}) {
2572
3179
  const plannedScene = event.data.scene;
2573
3180
  planned[position] = plannedScene;
2574
3181
  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);
3182
+ const visualPreparation = preparation.prepareVisual(plannedScene);
2591
3183
  const narrated = narrating.then(async () => {
2592
3184
  const supplied = plannedScene.narration?.trim();
2593
3185
  if (supplied) return supplied;
@@ -2623,7 +3215,7 @@ function useVideoChatSession(options = {}) {
2623
3215
  const withNarration = line ? { ...visual, narration: line } : visual;
2624
3216
  const group = plannedScene.narrationGroup;
2625
3217
  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);
3218
+ ready[position] = group ? withNarration : preparation.pace(withNarration, spoken?.seconds);
2627
3219
  flush();
2628
3220
  }).catch((cause) => {
2629
3221
  if (!isCurrent() || currentAttempt !== attempt) return;
@@ -2664,7 +3256,7 @@ function useVideoChatSession(options = {}) {
2664
3256
  try {
2665
3257
  response = await untilAborted(runAttempt(attempt), controller.signal);
2666
3258
  } 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;
3259
+ 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
3260
  attempt += 1;
2669
3261
  ready = [];
2670
3262
  received = [];
@@ -2727,19 +3319,19 @@ function useVideoChatSession(options = {}) {
2727
3319
  if (runRef.current === run) inFlightRef.current = void 0;
2728
3320
  }
2729
3321
  }, [request, endTiming]);
2730
- const pause = useCallback2(() => {
3322
+ const pause = useCallback3(() => {
2731
3323
  finishStall();
2732
3324
  heldRef.current = true;
2733
3325
  voiceRef.current.pause();
2734
3326
  dispatch({ type: "pause" });
2735
3327
  }, [finishStall]);
2736
- const resume = useCallback2(() => {
3328
+ const resume = useCallback3(() => {
2737
3329
  heldRef.current = false;
2738
3330
  voiceRef.current.resume();
2739
3331
  dispatch({ type: "resume" });
2740
3332
  flushRef.current?.();
2741
3333
  }, []);
2742
- const prepareSavedGroups = useCallback2(async (video) => {
3334
+ const prepareSavedGroups = useCallback3(async (video) => {
2743
3335
  validateNarrationGroups(video.scenes);
2744
3336
  const groups = new Map(video.scenes.flatMap((scene) => scene.narrationGroup ? [[scene.narrationGroup.id, scene.narrationGroup]] : []));
2745
3337
  for (const group of groups.values()) {
@@ -2747,7 +3339,7 @@ function useVideoChatSession(options = {}) {
2747
3339
  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
3340
  }
2749
3341
  }, []);
2750
- const replay = useCallback2(() => {
3342
+ const replay = useCallback3(() => {
2751
3343
  const turn = stateRef.current.turns.find((entry) => entry.id === stateRef.current.shownTurnId);
2752
3344
  if (!turn?.completed || !turn.video) return;
2753
3345
  runRef.current += 1;
@@ -2774,7 +3366,7 @@ function useVideoChatSession(options = {}) {
2774
3366
  });
2775
3367
  } else dispatch({ type: "replay" });
2776
3368
  }, [endTiming, prepareSavedGroups]);
2777
- const selectTurn = useCallback2((id) => {
3369
+ const selectTurn = useCallback3((id) => {
2778
3370
  const turn = stateRef.current.turns.find((entry) => entry.id === id);
2779
3371
  if (!turn?.completed || !turn.video) return;
2780
3372
  runRef.current += 1;
@@ -2801,19 +3393,19 @@ function useVideoChatSession(options = {}) {
2801
3393
  });
2802
3394
  } else dispatch({ type: "select", id });
2803
3395
  }, [endTiming, prepareSavedGroups]);
2804
- const reset = useCallback2(() => {
3396
+ const reset = useCallback3(() => {
2805
3397
  cancel("Session reset");
2806
3398
  heldRef.current = false;
2807
3399
  voiceRef.current.resume();
2808
3400
  dispatch({ type: "reset" });
2809
3401
  }, [cancel]);
2810
- const restoreSession = useCallback2((turns) => {
3402
+ const restoreSession = useCallback3((turns) => {
2811
3403
  cancel("Restoring session");
2812
3404
  heldRef.current = false;
2813
3405
  voiceRef.current.resume();
2814
3406
  dispatch({ type: "restore", turns: turns.filter((turn) => turn.completed && turn.video).slice(-100) });
2815
3407
  }, [cancel]);
2816
- const setMuted = useCallback2((muted) => dispatch({ type: "mute", value: muted }), []);
3408
+ const setMuted = useCallback3((muted) => dispatch({ type: "mute", value: muted }), []);
2817
3409
  const currentTurn = state.turns.at(-1);
2818
3410
  const shownTurn = state.turns.find((turn) => turn.id === state.shownTurnId) ?? currentTurn;
2819
3411
  const availableModes = state.capabilities?.modes ?? ["cinematic"];
@@ -2822,13 +3414,13 @@ function useVideoChatSession(options = {}) {
2822
3414
  const transcript = shownTurn && shownTurn === currentTurn && state.playback?.kind !== "video" ? fullTranscript.slice(0, (shownTurn.opening ? 1 : 0) + state.spokenUpTo + 1) : fullTranscript;
2823
3415
  const playbackKey = state.playerKey;
2824
3416
  const playerProps = state.playback ? {
2825
- ...options.templates ? { templates: options.templates } : {},
2826
3417
  ...state.playback.kind === "stream" ? { stream: state.playback.stream } : { video: state.playback.video },
2827
3418
  autoPlay: true,
2828
3419
  paused: state.status === "paused",
2829
3420
  controls: false,
2830
3421
  narrationReady: narration.isReady,
2831
3422
  narrationTime: narration.getTime,
3423
+ narrationActive: narration.isSpeaking,
2832
3424
  orientation: shownTurn?.fixedOrientation ? shownTurn.orientation : "auto",
2833
3425
  onFramePresented: () => {
2834
3426
  const timing = firstFrameRef.current;
@@ -2849,15 +3441,23 @@ function useVideoChatSession(options = {}) {
2849
3441
  elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt))
2850
3442
  });
2851
3443
  },
2852
- onStallChange: (stalled) => {
3444
+ onPlaybackMetric: options.onPlaybackMetric ? (metric) => {
3445
+ const timing = firstFrameRef.current;
3446
+ if (stateRef.current.playerKey !== playbackKey || !timing?.active || state.playback?.kind !== "stream") return;
3447
+ reportMetric({ ...metric, turnId: timing.turnId, mode: timing.mode, elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt)) });
3448
+ } : void 0,
3449
+ onStallChange: (stalled, reason = "scene-generation") => {
2853
3450
  if (stateRef.current.playerKey !== playbackKey) return;
2854
- if (stalled) voiceRef.current.pause();
3451
+ if (stalled && reason !== "speech") voiceRef.current.pause();
2855
3452
  else if (!heldRef.current) voiceRef.current.resume();
2856
3453
  if (state.playback?.kind !== "stream") return;
2857
3454
  const timing = firstFrameRef.current;
2858
3455
  if (!timing?.active || !timing.reported) return;
2859
3456
  if (!stalled) finishStall();
2860
- else if (!heldRef.current && timing.stallStartedAt == null) timing.stallStartedAt = monotonicNow();
3457
+ else if (!heldRef.current && timing.stallStartedAt == null) {
3458
+ timing.stallStartedAt = monotonicNow();
3459
+ timing.stallReason = reason;
3460
+ }
2861
3461
  },
2862
3462
  onSceneChange: (scene, index) => {
2863
3463
  if (stateRef.current.playerKey !== playbackKey) return;
@@ -2907,8 +3507,8 @@ function useVideoChatSession(options = {}) {
2907
3507
  }
2908
3508
 
2909
3509
  // 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";
3510
+ import { useEffect as useEffect8, useLayoutEffect, useRef as useRef7, useState as useState4 } from "react";
3511
+ import { jsx as jsx5 } from "react/jsx-runtime";
2912
3512
  function splitCaptionPages(text, fits) {
2913
3513
  const pages = [];
2914
3514
  let rest = Array.from(text);
@@ -2941,14 +3541,14 @@ function captionPageAt(pages, elapsed, duration) {
2941
3541
  return Math.max(0, pages.length - 1);
2942
3542
  }
2943
3543
  function CaptionPages({ text, getProgress }) {
2944
- const line = useRef4(null);
2945
- const lastProgress = useRef4(void 0);
2946
- const getter = useRef4(getProgress);
3544
+ const line = useRef7(null);
3545
+ const lastProgress = useRef7(void 0);
3546
+ const getter = useRef7(getProgress);
2947
3547
  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();
3548
+ const [source, setSource] = useState4(text);
3549
+ const [pages, setPages] = useState4([]);
3550
+ const [index, setIndex] = useState4(0);
3551
+ const [timing, setTiming] = useState4();
2952
3552
  useLayoutEffect(() => {
2953
3553
  const element = line.current;
2954
3554
  if (!element) return;
@@ -2991,7 +3591,7 @@ function CaptionPages({ text, getProgress }) {
2991
3591
  document.fonts?.removeEventListener("loadingdone", measure);
2992
3592
  };
2993
3593
  }, [source]);
2994
- useEffect5(() => {
3594
+ useEffect8(() => {
2995
3595
  let frame = 0;
2996
3596
  const update = () => {
2997
3597
  const observed = text ? getter.current() : void 0;
@@ -3006,14 +3606,14 @@ function CaptionPages({ text, getProgress }) {
3006
3606
  update();
3007
3607
  return () => cancelAnimationFrame(frame);
3008
3608
  }, [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] ?? "" });
3609
+ return /* @__PURE__ */ jsx5("p", { ref: line, className: "line", "aria-live": "polite", "data-caption-timing": timing, "data-caption-page": index, children: pages[index] ?? "" });
3010
3610
  }
3011
3611
 
3012
3612
  // 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";
3613
+ 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
3614
 
3015
3615
  // src/video-chat/icons.tsx
3016
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
3616
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
3017
3617
  var stroke = {
3018
3618
  fill: "none",
3019
3619
  stroke: "currentColor",
@@ -3022,50 +3622,50 @@ var stroke = {
3022
3622
  strokeLinejoin: "round"
3023
3623
  };
3024
3624
  function Glyph({ children }) {
3025
- return /* @__PURE__ */ jsx4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", ...stroke, children });
3625
+ return /* @__PURE__ */ jsx6("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", ...stroke, children });
3026
3626
  }
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" })
3627
+ var Sessions = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3628
+ /* @__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" }),
3629
+ /* @__PURE__ */ jsx6("path", { d: "M17 8h2a2 2 0 0 1 2 2v11l-4-3h-6a2 2 0 0 1-2-2v-2" })
3030
3630
  ] });
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" })
3631
+ var Plus = () => /* @__PURE__ */ jsx6(Glyph, { children: /* @__PURE__ */ jsx6("path", { d: "M12 5v14M5 12h14" }) });
3632
+ var Gear = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3633
+ /* @__PURE__ */ jsx6("circle", { cx: "12", cy: "12", r: "3" }),
3634
+ /* @__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
3635
  ] });
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" })
3636
+ var Sound = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3637
+ /* @__PURE__ */ jsx6("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3638
+ /* @__PURE__ */ jsx6("path", { d: "M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" })
3039
3639
  ] });
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" })
3640
+ var Muted = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3641
+ /* @__PURE__ */ jsx6("path", { d: "M11 5 6 9H2v6h4l5 4z" }),
3642
+ /* @__PURE__ */ jsx6("path", { d: "m22 9-6 6M16 9l6 6" })
3043
3643
  ] });
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" })
3644
+ var Mic = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3645
+ /* @__PURE__ */ jsx6("rect", { x: "9", y: "2", width: "6", height: "12", rx: "3" }),
3646
+ /* @__PURE__ */ jsx6("path", { d: "M5 11a7 7 0 0 0 14 0M12 18v4" })
3047
3647
  ] });
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" })
3648
+ var Send = () => /* @__PURE__ */ jsx6(Glyph, { children: /* @__PURE__ */ jsx6("path", { d: "M12 19V5M5 12l7-7 7 7" }) });
3649
+ var Stop = () => /* @__PURE__ */ jsxs4("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", fill: "currentColor", children: [
3650
+ /* @__PURE__ */ jsx6("rect", { x: "7", y: "5", width: "3.4", height: "14", rx: "1.4" }),
3651
+ /* @__PURE__ */ jsx6("rect", { x: "13.6", y: "5", width: "3.4", height: "14", rx: "1.4" })
3052
3652
  ] });
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" })
3653
+ 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" }) });
3654
+ var Replay = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3655
+ /* @__PURE__ */ jsx6("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }),
3656
+ /* @__PURE__ */ jsx6("path", { d: "M3 3v5h5" })
3057
3657
  ] });
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" })
3658
+ 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" }) });
3659
+ var Close = () => /* @__PURE__ */ jsx6(Glyph, { children: /* @__PURE__ */ jsx6("path", { d: "M6 6l12 12M18 6 6 18" }) });
3660
+ var Warning = () => /* @__PURE__ */ jsxs4(Glyph, { children: [
3661
+ /* @__PURE__ */ jsx6("circle", { cx: "12", cy: "12", r: "9" }),
3662
+ /* @__PURE__ */ jsx6("path", { d: "M12 8v5M12 16.2v.1" })
3063
3663
  ] });
3064
3664
 
3065
3665
  // src/video-chat/use-dismiss.ts
3066
- import { useEffect as useEffect6 } from "react";
3666
+ import { useEffect as useEffect9 } from "react";
3067
3667
  function useDismiss(open, close, surfaces) {
3068
- useEffect6(() => {
3668
+ useEffect9(() => {
3069
3669
  if (!open) return;
3070
3670
  const onKeyDown = (event) => {
3071
3671
  if (event.key !== "Escape") return;
@@ -3087,7 +3687,7 @@ function useDismiss(open, close, surfaces) {
3087
3687
  }, [open, close, surfaces]);
3088
3688
  }
3089
3689
  function useFocusTrap(active, surface) {
3090
- useEffect6(() => {
3690
+ useEffect9(() => {
3091
3691
  if (!active) return;
3092
3692
  const returnTo = document.activeElement;
3093
3693
  const focusable = () => Array.from(
@@ -3120,12 +3720,12 @@ function useFocusTrap(active, surface) {
3120
3720
  }
3121
3721
 
3122
3722
  // 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";
3723
+ import { useEffect as useEffect10, useId, useRef as useRef8, useState as useState5 } from "react";
3724
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
3125
3725
  function Frame({ media, poster, playing, onReady, onError, revealWhenReady = false }) {
3126
- const video = useRef5(null);
3127
- const [playingUrl, setPlayingUrl] = useState4(null);
3128
- useEffect7(() => {
3726
+ const video = useRef8(null);
3727
+ const [playingUrl, setPlayingUrl] = useState5(null);
3728
+ useEffect10(() => {
3129
3729
  const element = video.current;
3130
3730
  if (!element || playing === void 0) return;
3131
3731
  if (playing) void element.play().catch(() => void 0);
@@ -3133,16 +3733,16 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
3133
3733
  }, [playing]);
3134
3734
  if (!media) return null;
3135
3735
  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 });
3736
+ return /* @__PURE__ */ jsx7("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady, onError });
3137
3737
  }
3138
3738
  const ready = () => {
3139
3739
  setPlayingUrl(media.url);
3140
3740
  onReady?.();
3141
3741
  };
3142
3742
  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(
3743
+ if (media.type === "image") return /* @__PURE__ */ jsx7("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
3744
+ return /* @__PURE__ */ jsxs5(Fragment2, { children: [
3745
+ /* @__PURE__ */ jsx7(
3146
3746
  "video",
3147
3747
  {
3148
3748
  ref: video,
@@ -3163,15 +3763,15 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
3163
3763
  "aria-hidden": "true"
3164
3764
  }
3165
3765
  ),
3166
- poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx5("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3766
+ poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx7("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3167
3767
  ] });
3168
3768
  }
3169
3769
  function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3170
- const railRef = useRef5(null);
3770
+ const railRef = useRef8(null);
3171
3771
  const railId = useId();
3172
- const [at, setAt] = useState4(0);
3173
- const [overflow, setOverflow] = useState4({ before: false, after: false });
3174
- useEffect7(() => {
3772
+ const [at, setAt] = useState5(0);
3773
+ const [overflow, setOverflow] = useState5({ before: false, after: false });
3774
+ useEffect10(() => {
3175
3775
  const rail = railRef.current;
3176
3776
  if (!browse || !rail) return;
3177
3777
  const update = () => setOverflow({ before: rail.scrollLeft > 2, after: rail.scrollLeft + rail.clientWidth < rail.scrollWidth - 2 });
@@ -3190,8 +3790,8 @@ function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3190
3790
  rail.scrollBy({ left: direction * rail.clientWidth, behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
3191
3791
  };
3192
3792
  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(
3793
+ const cards = /* @__PURE__ */ jsxs5(Fragment2, { children: [
3794
+ /* @__PURE__ */ jsx7("ul", { className: "cards", id: railId, ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsxs5(
3195
3795
  "button",
3196
3796
  {
3197
3797
  type: "button",
@@ -3200,13 +3800,13 @@ function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3200
3800
  onPointerEnter: () => setAt(index),
3201
3801
  onClick: () => onAsk(card),
3202
3802
  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 })
3803
+ /* @__PURE__ */ jsx7(Frame, { media: card.media, poster: true, playing: index === at }),
3804
+ /* @__PURE__ */ jsx7("span", { className: "card-wash", "aria-hidden": "true" }),
3805
+ /* @__PURE__ */ jsx7("span", { className: "card-prompt", children: card.prompt })
3206
3806
  ]
3207
3807
  }
3208
3808
  ) }, 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(
3809
+ suggestions.length > 1 && /* @__PURE__ */ jsx7("div", { className: "card-dots", role: "group", "aria-label": `${label} navigation`, children: suggestions.map((card, index) => /* @__PURE__ */ jsx7(
3210
3810
  "button",
3211
3811
  {
3212
3812
  type: "button",
@@ -3223,39 +3823,39 @@ function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
3223
3823
  card.prompt
3224
3824
  )) })
3225
3825
  ] });
3226
- return browse ? /* @__PURE__ */ jsxs4("div", { className: "suggestion-rail", children: [
3826
+ return browse ? /* @__PURE__ */ jsxs5("div", { className: "suggestion-rail", children: [
3227
3827
  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" })
3828
+ (overflow.before || overflow.after) && /* @__PURE__ */ jsxs5("div", { className: "rail-arrows", children: [
3829
+ /* @__PURE__ */ jsx7("button", { type: "button", "aria-label": "Previous suggestions", "aria-controls": railId, disabled: !overflow.before, onClick: () => move(-1), children: "\u2039" }),
3830
+ /* @__PURE__ */ jsx7("button", { type: "button", "aria-label": "Next suggestions", "aria-controls": railId, disabled: !overflow.after, onClick: () => move(1), children: "\u203A" })
3231
3831
  ] })
3232
3832
  ] }) : cards;
3233
3833
  }
3234
3834
 
3235
3835
  // src/video-chat/welcome.tsx
3236
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
3836
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3237
3837
  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: [
3838
+ return /* @__PURE__ */ jsxs6("div", { className: "welcome", children: [
3839
+ /* @__PURE__ */ jsx8(Frame, { media: data?.hero ?? null, poster: true }),
3840
+ /* @__PURE__ */ jsx8("div", { className: "welcome-wash", "aria-hidden": "true" }),
3841
+ /* @__PURE__ */ jsxs6("div", { className: "welcome-body", children: [
3842
+ /* @__PURE__ */ jsx8("h1", { className: "welcome-title", children: title ?? /* @__PURE__ */ jsxs6(Fragment3, { children: [
3243
3843
  "An AI chat that responds",
3244
- /* @__PURE__ */ jsx6("br", {}),
3245
- /* @__PURE__ */ jsx6("em", { children: "in video, not text." })
3844
+ /* @__PURE__ */ jsx8("br", {}),
3845
+ /* @__PURE__ */ jsx8("em", { children: "in video, not text." })
3246
3846
  ] }) }),
3247
- /* @__PURE__ */ jsx6(SuggestionCards, { browse: true, suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
3847
+ /* @__PURE__ */ jsx8(SuggestionCards, { browse: true, suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
3248
3848
  ] })
3249
3849
  ] });
3250
3850
  }
3251
3851
 
3252
3852
  // 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";
3853
+ import { useLayoutEffect as useLayoutEffect2, useRef as useRef9, useState as useState6 } from "react";
3854
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3255
3855
  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);
3856
+ const root = useRef9(null);
3857
+ const [size, setSize] = useState6({ width: 1080, height: 1080 });
3858
+ const [titleBottom, setTitleBottom] = useState6(0);
3259
3859
  useLayoutEffect2(() => {
3260
3860
  const element = root.current;
3261
3861
  if (!element) return;
@@ -3273,28 +3873,17 @@ function OpeningChapter({ title, preparing = false }) {
3273
3873
  if (title2) observer.observe(title2);
3274
3874
  return () => observer.disconnect();
3275
3875
  }, [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" })
3876
+ return /* @__PURE__ */ jsxs7("div", { ref: root, "data-opening-chapter": true, className: "opening-chapter", children: [
3877
+ /* @__PURE__ */ jsx9(TitleSceneTemplate, { variables: { title }, width: size.width, height: size.height, progress: 0.3 }),
3878
+ preparing && /* @__PURE__ */ jsxs7("div", { className: "video-preparation", style: { top: titleBottom + 24 }, role: "status", "aria-label": "Video preparation", children: [
3879
+ /* @__PURE__ */ jsx9("span", { className: "video-preparation-spinner", "aria-hidden": "true" }),
3880
+ /* @__PURE__ */ jsx9("span", { className: "video-preparation-text", children: "Preparing your video\u2026" })
3292
3881
  ] })
3293
3882
  ] });
3294
3883
  }
3295
3884
 
3296
3885
  // src/video-chat/use-voice-input.ts
3297
- import { useCallback as useCallback3, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
3886
+ import { useCallback as useCallback4, useEffect as useEffect11, useRef as useRef10, useState as useState7 } from "react";
3298
3887
  function recognitionConstructor() {
3299
3888
  if (typeof window === "undefined") return void 0;
3300
3889
  const holder = window;
@@ -3364,19 +3953,19 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3364
3953
  return clip ? transcribeRecording(clip, signal, options) : "";
3365
3954
  }
3366
3955
  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);
3956
+ const [supported, setSupported] = useState7(false);
3957
+ const [listening, setListening] = useState7(false);
3958
+ const [error, setError] = useState7();
3959
+ const [thinking, setThinking] = useState7(false);
3960
+ const recognitionRef = useRef10(void 0);
3961
+ const useRecorderRef = useRef10(false);
3962
+ const operationRef = useRef10(void 0);
3963
+ const handlerRef = useRef10(onTranscript);
3375
3964
  handlerRef.current = onTranscript;
3376
- useEffect8(() => {
3965
+ useEffect11(() => {
3377
3966
  setSupported(supportsVoiceInput(transcriptionAvailable));
3378
3967
  }, [transcriptionAvailable]);
3379
- const stop = useCallback3(() => {
3968
+ const stop = useCallback4(() => {
3380
3969
  recognitionRef.current?.abort();
3381
3970
  recognitionRef.current = void 0;
3382
3971
  const operation = operationRef.current;
@@ -3386,7 +3975,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3386
3975
  setListening(false);
3387
3976
  setThinking(false);
3388
3977
  }, []);
3389
- useEffect8(() => () => {
3978
+ useEffect11(() => () => {
3390
3979
  recognitionRef.current?.abort();
3391
3980
  recognitionRef.current = void 0;
3392
3981
  const operation = operationRef.current;
@@ -3394,7 +3983,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3394
3983
  operation?.controller.abort();
3395
3984
  operation?.finish?.();
3396
3985
  }, []);
3397
- const finish = useCallback3(() => {
3986
+ const finish = useCallback4(() => {
3398
3987
  const recognition = recognitionRef.current;
3399
3988
  if (recognition) {
3400
3989
  recognition.stop();
@@ -3408,7 +3997,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3408
3997
  }
3409
3998
  stop();
3410
3999
  }, [stop]);
3411
- const record = useCallback3(async () => {
4000
+ const record = useCallback4(async () => {
3412
4001
  if (!transcriptionAvailable || !recorderAvailable()) {
3413
4002
  setError("Server transcription is not configured.");
3414
4003
  return;
@@ -3446,7 +4035,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3446
4035
  }
3447
4036
  }
3448
4037
  }, [transcriptionAvailable, options.endpoint, options.headers, options.credentials, options.fetcher]);
3449
- const toggle = useCallback3(() => {
4038
+ const toggle = useCallback4(() => {
3450
4039
  if (listening) {
3451
4040
  finish();
3452
4041
  return;
@@ -3498,18 +4087,18 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3498
4087
  }
3499
4088
 
3500
4089
  // src/video-chat/use-immersive-controls.ts
3501
- import { useCallback as useCallback4, useEffect as useEffect9, useRef as useRef8, useState as useState7 } from "react";
4090
+ import { useCallback as useCallback5, useEffect as useEffect12, useRef as useRef11, useState as useState8 } from "react";
3502
4091
  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(() => {
4092
+ const [visible, setVisible] = useState8(true);
4093
+ const hovered = useRef11(false);
4094
+ const focused = useRef11(false);
4095
+ const previousCaptions = useRef11(false);
4096
+ const timer = useRef11(null);
4097
+ const clearTimer = useCallback5(() => {
3509
4098
  if (timer.current !== null) clearTimeout(timer.current);
3510
4099
  timer.current = null;
3511
4100
  }, []);
3512
- const reveal = useCallback4(() => {
4101
+ const reveal = useCallback5(() => {
3513
4102
  clearTimer();
3514
4103
  setVisible(true);
3515
4104
  if (playing && !pinned && !hovered.current && !focused.current) {
@@ -3519,7 +4108,7 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3519
4108
  }, 2e3);
3520
4109
  }
3521
4110
  }, [clearTimer, playing, pinned]);
3522
- useEffect9(() => {
4111
+ useEffect12(() => {
3523
4112
  const firstCaption = hasCaptions && !previousCaptions.current;
3524
4113
  previousCaptions.current = hasCaptions;
3525
4114
  if (firstCaption && playing && !pinned && !focused.current) {
@@ -3531,21 +4120,21 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3531
4120
  }
3532
4121
  return clearTimer;
3533
4122
  }, [hasCaptions, playing, pinned, reveal, clearTimer]);
3534
- const onPointerEnter = useCallback4((event) => {
4123
+ const onPointerEnter = useCallback5((event) => {
3535
4124
  if (event?.pointerType === "touch") return;
3536
4125
  hovered.current = true;
3537
4126
  reveal();
3538
4127
  }, [reveal]);
3539
- const onPointerLeave = useCallback4(() => {
4128
+ const onPointerLeave = useCallback5(() => {
3540
4129
  if (!hovered.current) return;
3541
4130
  hovered.current = false;
3542
4131
  reveal();
3543
4132
  }, [reveal]);
3544
- const onFocusCapture = useCallback4((event) => {
4133
+ const onFocusCapture = useCallback5((event) => {
3545
4134
  focused.current = event.target.matches(":focus-visible");
3546
4135
  reveal();
3547
4136
  }, [reveal]);
3548
- const onBlurCapture = useCallback4((event) => {
4137
+ const onBlurCapture = useCallback5((event) => {
3549
4138
  if (focused.current && !event.currentTarget.contains(event.relatedTarget)) {
3550
4139
  focused.current = false;
3551
4140
  reveal();
@@ -3555,9 +4144,9 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3555
4144
  }
3556
4145
 
3557
4146
  // src/video-chat/logo.tsx
3558
- import { jsx as jsx8 } from "react/jsx-runtime";
4147
+ import { jsx as jsx10 } from "react/jsx-runtime";
3559
4148
  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 });
4149
+ 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
4150
  }
3562
4151
 
3563
4152
  // src/video-chat/modes.ts
@@ -3568,11 +4157,11 @@ var visualModes = [
3568
4157
  var defaultMode = visualModes[0];
3569
4158
 
3570
4159
  // src/video-chat/video-chat.tsx
3571
- import { Fragment as Fragment4, jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
4160
+ import { Fragment as Fragment4, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
3572
4161
  var DESKTOP_WIDTH = 900;
3573
4162
  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(() => {
4163
+ const [portrait, setPortrait] = useState9(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
4164
+ useEffect13(() => {
3576
4165
  if (typeof window.matchMedia !== "function") return;
3577
4166
  const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`);
3578
4167
  const update = () => setPortrait(query.matches);
@@ -3583,23 +4172,25 @@ function useViewportOrientation() {
3583
4172
  return portrait ? "portrait" : "landscape";
3584
4173
  }
3585
4174
  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)) });
4175
+ 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
4176
  }
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);
4177
+ function VideoChat({ options = {}, className, welcomeTitle, branding, showRecoveryNotice = false }) {
4178
+ const appName = branding?.name.trim() || "VanillaSky";
4179
+ const customHome = safeHomeUrl(branding?.homeUrl);
4180
+ const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState9();
4181
+ const [draft, setDraft] = useState9("");
4182
+ const [selectedMode, setSelectedMode] = useState9();
4183
+ const [savedSessions, setSavedSessions] = useState9([]);
4184
+ const [historyOpen, setHistoryOpen] = useState9(false);
4185
+ const [settingsOpen, setSettingsOpen] = useState9(false);
4186
+ const [aboutOpen, setAboutOpen] = useState9(false);
4187
+ const [captionsOn, setCaptionsOn] = useState9(true);
4188
+ const [captionsExpanded, setCaptionsExpanded] = useState9(false);
4189
+ const [alwaysShowControls, setAlwaysShowControls] = useState9(false);
4190
+ const [editing, setEditing] = useState9(false);
4191
+ const resumeAfterInput = useRef12(false);
4192
+ const panelRef = useRef12(null);
4193
+ const composerRef = useRef12(null);
3603
4194
  const viewportOrientation = useViewportOrientation();
3604
4195
  const sessionOrientation = options.orientation ?? viewportOrientation;
3605
4196
  const { chat, restoreSession, getCaptionProgress } = useVideoChatSession({
@@ -3607,8 +4198,8 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3607
4198
  orientation: sessionOrientation,
3608
4199
  mode: selectedMode ?? options.mode
3609
4200
  });
3610
- const observedMode = useRef9(void 0);
3611
- useEffect10(() => {
4201
+ const observedMode = useRef12(void 0);
4202
+ useEffect13(() => {
3612
4203
  const turn = chat.currentTurn;
3613
4204
  if (!turn?.mode) {
3614
4205
  observedMode.current = void 0;
@@ -3623,11 +4214,11 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3623
4214
  const historyId = `${instanceId}-history`;
3624
4215
  const settingsId = `${instanceId}-settings`;
3625
4216
  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);
4217
+ const inputRef = useRef12(null);
4218
+ const historyRef = useRef12(null);
4219
+ const historyButtonRef = useRef12(null);
4220
+ const settingsRef = useRef12(null);
4221
+ const settingsButtonRef = useRef12(null);
3631
4222
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3632
4223
  endpoint: options.endpoint,
3633
4224
  headers: options.headers,
@@ -3636,13 +4227,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3636
4227
  });
3637
4228
  const historySurfaces = useMemo3(() => [historyRef, historyButtonRef], []);
3638
4229
  const settingsSurfaces = useMemo3(() => [settingsRef, settingsButtonRef], []);
3639
- const closeHistory = useCallback5(() => setHistoryOpen(false), []);
3640
- const closeSettings = useCallback5(() => setSettingsOpen(false), []);
4230
+ const closeHistory = useCallback6(() => setHistoryOpen(false), []);
4231
+ const closeSettings = useCallback6(() => setSettingsOpen(false), []);
3641
4232
  useDismiss(historyOpen, closeHistory, historySurfaces);
3642
4233
  useDismiss(settingsOpen, closeSettings, settingsSurfaces);
3643
4234
  useFocusTrap(settingsOpen, settingsRef);
3644
4235
  useFocusTrap(historyOpen, historyRef);
3645
- const ask = useCallback5((value) => {
4236
+ const ask = useCallback6((value) => {
3646
4237
  const prompt = (typeof value === "string" ? value : value.prompt).trim();
3647
4238
  if (!prompt) return;
3648
4239
  setDraft("");
@@ -3657,7 +4248,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3657
4248
  opening: value.opening
3658
4249
  });
3659
4250
  }, [chat, listen]);
3660
- const newSession = useCallback5(() => {
4251
+ const newSession = useCallback6(() => {
3661
4252
  if (chat.turns.length === 0) {
3662
4253
  setHistoryOpen(false);
3663
4254
  setSettingsOpen(false);
@@ -3679,13 +4270,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3679
4270
  const shown = chat.shownTurn;
3680
4271
  const showing = chat.playerProps != null;
3681
4272
  const handoffKey = `${shown?.id ?? ""}:${chat.playerKey}`;
3682
- const [presentedBody, setPresentedBody] = useState8();
3683
- const handoff = useRef9({ key: handoffKey, live: showing, active: false, frame: 0 });
4273
+ const [presentedBody, setPresentedBody] = useState9();
4274
+ const handoff = useRef12({ key: handoffKey, live: showing, active: false, frame: 0 });
3684
4275
  const handoffStopped = chat.status === "error" || chat.status === "cancelled";
3685
4276
  const waitingForBody = showing && presentedBody !== handoffKey;
3686
4277
  const openingChapter = Boolean(shown?.prompt) && (!showing || waitingForBody) && chat.status !== "error" && chat.status !== "cancelled" && chat.status !== "ended";
3687
- const [preparingKey, setPreparingKey] = useState8();
3688
- useEffect10(() => {
4278
+ const [preparingKey, setPreparingKey] = useState9();
4279
+ useEffect13(() => {
3689
4280
  setPreparingKey(void 0);
3690
4281
  if (!openingChapter || !shown?.opening || chat.speaking) return;
3691
4282
  const timer = setTimeout(() => setPreparingKey(shown.id), 1e3);
@@ -3724,13 +4315,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3724
4315
  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
4316
  const shownOrientation = shown?.orientation ?? sessionOrientation;
3726
4317
  const stageOrientation = shown?.fixedOrientation ? shownOrientation : sessionOrientation;
3727
- useEffect10(() => {
4318
+ useEffect13(() => {
3728
4319
  setCaptionsExpanded(false);
3729
4320
  }, [chat.playbackEnded, shown?.id, chat.playerKey]);
3730
4321
  const line = chat.caption ?? "";
3731
4322
  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(() => {
4323
+ 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;
4324
+ const cancelInput = useCallback6(() => {
3734
4325
  listen.stop();
3735
4326
  setDraft("");
3736
4327
  setEditing(false);
@@ -3770,7 +4361,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3770
4361
  observer.observe(composer);
3771
4362
  return () => observer.disconnect();
3772
4363
  }, []);
3773
- return /* @__PURE__ */ jsxs7(
4364
+ return /* @__PURE__ */ jsxs8(
3774
4365
  "div",
3775
4366
  {
3776
4367
  className: `vanillasky-video-chat${className ? ` ${className}` : ""}`,
@@ -3782,14 +4373,24 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3782
4373
  onPointerDown: controls.reveal,
3783
4374
  onKeyDownCapture: controls.reveal,
3784
4375
  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(
4376
+ /* @__PURE__ */ jsxs8("header", { className: "chrome", ...controlEvents, children: [
4377
+ /* @__PURE__ */ jsx11("div", { className: "session-brand", children: /* @__PURE__ */ jsx11(
4378
+ "a",
4379
+ {
4380
+ className: "home-link",
4381
+ href: customHome ?? "/",
4382
+ "aria-label": branding ? `${appName} home` : "Home",
4383
+ style: branding ? { color: "inherit", textDecoration: "none" } : void 0,
4384
+ onClick: (event) => {
4385
+ if (customHome || window.location.pathname !== "/" || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
4386
+ event.preventDefault();
4387
+ newSession();
4388
+ },
4389
+ children: branding ? branding.logo ?? appName : /* @__PURE__ */ jsx11(Logo, {})
4390
+ }
4391
+ ) }),
4392
+ /* @__PURE__ */ jsxs8("div", { className: "group", children: [
4393
+ /* @__PURE__ */ jsxs8(
3793
4394
  "button",
3794
4395
  {
3795
4396
  ref: historyButtonRef,
@@ -3804,12 +4405,12 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3804
4405
  setSettingsOpen(false);
3805
4406
  },
3806
4407
  children: [
3807
- /* @__PURE__ */ jsx9(Sessions, {}),
3808
- /* @__PURE__ */ jsx9("span", { className: "nav-label", children: "Sessions" })
4408
+ /* @__PURE__ */ jsx11(Sessions, {}),
4409
+ /* @__PURE__ */ jsx11("span", { className: "nav-label", children: "Sessions" })
3809
4410
  ]
3810
4411
  }
3811
4412
  ),
3812
- /* @__PURE__ */ jsx9(
4413
+ /* @__PURE__ */ jsx11(
3813
4414
  "button",
3814
4415
  {
3815
4416
  ref: settingsButtonRef,
@@ -3823,10 +4424,10 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3823
4424
  setSettingsOpen((open) => !open);
3824
4425
  setHistoryOpen(false);
3825
4426
  },
3826
- children: /* @__PURE__ */ jsx9(Gear, {})
4427
+ children: /* @__PURE__ */ jsx11(Gear, {})
3827
4428
  }
3828
4429
  ),
3829
- /* @__PURE__ */ jsx9(
4430
+ /* @__PURE__ */ jsx11(
3830
4431
  "button",
3831
4432
  {
3832
4433
  type: "button",
@@ -3834,50 +4435,49 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3834
4435
  "aria-label": chat.muted ? "Turn the voice on" : "Turn the voice off",
3835
4436
  "aria-pressed": chat.muted,
3836
4437
  onClick: () => chat.setMuted(!chat.muted),
3837
- children: chat.muted ? /* @__PURE__ */ jsx9(Muted, {}) : /* @__PURE__ */ jsx9(Sound, {})
4438
+ children: chat.muted ? /* @__PURE__ */ jsx11(Muted, {}) : /* @__PURE__ */ jsx11(Sound, {})
3838
4439
  }
3839
4440
  )
3840
4441
  ] })
3841
4442
  ] }),
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(
4443
+ /* @__PURE__ */ jsxs8("div", { className: "stage-area", children: [
4444
+ /* @__PURE__ */ jsxs8("div", { className: "stage", style: { background: "#000" }, children: [
4445
+ openingChapter && /* @__PURE__ */ jsx11(OpeningChapter, { preparing: preparingKey === shown.id && !chat.speaking, title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
4446
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx11(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
4447
+ chat.playerProps && /* @__PURE__ */ jsx11("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx11(
3847
4448
  VideoPlayer,
3848
4449
  {
3849
4450
  ...chat.playerProps,
3850
4451
  onSceneChange: cueBody,
3851
- templates: options.templates,
3852
4452
  orientation: stageOrientation,
3853
4453
  responsiveBreakpoint: DESKTOP_WIDTH,
3854
4454
  ariaLabel: chat.playerProps.video ? "Replay" : "The response"
3855
4455
  },
3856
4456
  chat.playerKey
3857
4457
  ) }),
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 })
4458
+ showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs8("div", { className: "ending", children: [
4459
+ /* @__PURE__ */ jsx11("div", { className: "ending-wash", "aria-hidden": "true" }),
4460
+ /* @__PURE__ */ jsxs8("div", { className: "ending-body", children: [
4461
+ /* @__PURE__ */ jsx11("p", { className: "ending-label", children: "Ask next" }),
4462
+ /* @__PURE__ */ jsx11(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3863
4463
  ] })
3864
4464
  ] })
3865
4465
  ] }),
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, {}) })
4466
+ historyOpen && /* @__PURE__ */ jsxs8("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "Sessions", children: [
4467
+ /* @__PURE__ */ jsxs8("div", { className: "popover-heading", children: [
4468
+ /* @__PURE__ */ jsx11("h2", { children: "Sessions" }),
4469
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx11(Close, {}) })
3870
4470
  ] }),
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: [
4471
+ /* @__PURE__ */ jsxs8("button", { type: "button", className: "history-row session-new", "aria-label": "New session", onClick: newSession, children: [
4472
+ /* @__PURE__ */ jsx11(Plus, {}),
4473
+ /* @__PURE__ */ jsxs8("span", { className: "prompt", children: [
3874
4474
  "New session",
3875
- /* @__PURE__ */ jsx9("small", { children: "Start a fresh conversation" })
4475
+ /* @__PURE__ */ jsx11("small", { children: "Start a fresh conversation" })
3876
4476
  ] })
3877
4477
  ] }),
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(
4478
+ /* @__PURE__ */ jsx11("h3", { className: "section-label", children: "Current session" }),
4479
+ chat.turns.length === 0 && /* @__PURE__ */ jsx11("p", { className: "history-empty", children: "Your questions will appear here." }),
4480
+ chat.turns.map((turn, index) => /* @__PURE__ */ jsxs8(
3881
4481
  "button",
3882
4482
  {
3883
4483
  type: "button",
@@ -3893,18 +4493,18 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3893
4493
  setHistoryOpen(false);
3894
4494
  },
3895
4495
  children: [
3896
- /* @__PURE__ */ jsx9("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3897
- /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
4496
+ /* @__PURE__ */ jsx11("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
4497
+ /* @__PURE__ */ jsxs8("span", { className: "prompt", children: [
3898
4498
  turn.prompt,
3899
- /* @__PURE__ */ jsx9("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
4499
+ /* @__PURE__ */ jsx11("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3900
4500
  ] })
3901
4501
  ]
3902
4502
  },
3903
4503
  turn.id
3904
4504
  )),
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: () => {
4505
+ savedSessions.length > 0 && /* @__PURE__ */ jsxs8(Fragment4, { children: [
4506
+ /* @__PURE__ */ jsx11("h3", { className: "section-label", children: "Earlier sessions" }),
4507
+ savedSessions.map((session) => /* @__PURE__ */ jsxs8("button", { type: "button", className: "history-row", onClick: () => {
3908
4508
  listen.stop();
3909
4509
  setDraft("");
3910
4510
  setEditing(false);
@@ -3915,10 +4515,10 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3915
4515
  setHistoryOpen(false);
3916
4516
  setCaptionsExpanded(false);
3917
4517
  }, children: [
3918
- /* @__PURE__ */ jsx9(Replay, {}),
3919
- /* @__PURE__ */ jsxs7("span", { className: "prompt", children: [
4518
+ /* @__PURE__ */ jsx11(Replay, {}),
4519
+ /* @__PURE__ */ jsxs8("span", { className: "prompt", children: [
3920
4520
  session.turns[0]?.prompt,
3921
- /* @__PURE__ */ jsxs7("small", { children: [
4521
+ /* @__PURE__ */ jsxs8("small", { children: [
3922
4522
  session.turns.length,
3923
4523
  " ",
3924
4524
  session.turns.length === 1 ? "answer" : "answers"
@@ -3927,7 +4527,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3927
4527
  ] }, session.id))
3928
4528
  ] })
3929
4529
  ] }),
3930
- settingsOpen && /* @__PURE__ */ jsxs7(
4530
+ settingsOpen && /* @__PURE__ */ jsxs8(
3931
4531
  "div",
3932
4532
  {
3933
4533
  ref: settingsRef,
@@ -3937,18 +4537,18 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3937
4537
  "aria-label": "Settings",
3938
4538
  "aria-modal": "true",
3939
4539
  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, {}) })
4540
+ /* @__PURE__ */ jsxs8("div", { className: "popover-heading", children: [
4541
+ /* @__PURE__ */ jsx11("h2", { children: "Settings" }),
4542
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx11(Close, {}) })
3943
4543
  ] }),
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 })
4544
+ /* @__PURE__ */ jsxs8("fieldset", { className: "playback-options", children: [
4545
+ /* @__PURE__ */ jsx11("legend", { children: "Video source" }),
4546
+ visualModes.filter((mode) => chat.availableModes.includes(mode.id)).map((mode) => /* @__PURE__ */ jsxs8("label", { className: "switch-row", children: [
4547
+ /* @__PURE__ */ jsxs8("span", { children: [
4548
+ /* @__PURE__ */ jsx11("strong", { children: mode.label }),
4549
+ /* @__PURE__ */ jsx11("small", { children: mode.note })
3950
4550
  ] }),
3951
- /* @__PURE__ */ jsx9(
4551
+ /* @__PURE__ */ jsx11(
3952
4552
  "input",
3953
4553
  {
3954
4554
  type: "radio",
@@ -3960,51 +4560,51 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
3960
4560
  )
3961
4561
  ] }, mode.id))
3962
4562
  ] }),
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" })
4563
+ /* @__PURE__ */ jsxs8("fieldset", { className: "playback-options", children: [
4564
+ /* @__PURE__ */ jsx11("legend", { children: "Watching" }),
4565
+ /* @__PURE__ */ jsxs8("label", { className: "switch-row", children: [
4566
+ /* @__PURE__ */ jsxs8("span", { children: [
4567
+ /* @__PURE__ */ jsx11("strong", { children: "Subtitles" }),
4568
+ /* @__PURE__ */ jsx11("small", { children: "Read along with the answer" })
3969
4569
  ] }),
3970
- /* @__PURE__ */ jsx9("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
4570
+ /* @__PURE__ */ jsx11("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3971
4571
  setCaptionsOn(event.target.checked);
3972
4572
  setCaptionsExpanded(false);
3973
4573
  } })
3974
4574
  ] }),
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" })
4575
+ /* @__PURE__ */ jsxs8("label", { className: "switch-row", children: [
4576
+ /* @__PURE__ */ jsxs8("span", { children: [
4577
+ /* @__PURE__ */ jsx11("strong", { children: "Keep controls visible" }),
4578
+ /* @__PURE__ */ jsx11("small", { children: "Keep the input bar on screen" })
3979
4579
  ] }),
3980
- /* @__PURE__ */ jsx9("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
4580
+ /* @__PURE__ */ jsx11("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3981
4581
  ] })
3982
4582
  ] }),
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: [
4583
+ branding?.showDeveloperLinks !== false && /* @__PURE__ */ jsxs8("nav", { className: "developer-links", "aria-label": "Build with VanillaSky", children: [
4584
+ /* @__PURE__ */ jsx11("p", { className: "section-label", children: "Build with VanillaSky" }),
4585
+ /* @__PURE__ */ jsxs8("a", { href: "https://github.com/VanillaSkyAi/video/blob/main/docs/getting-started.md", target: "_blank", rel: "noopener noreferrer", children: [
3986
4586
  "Docs",
3987
- /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2197" })
4587
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: "\u2197" })
3988
4588
  ] }),
3989
- /* @__PURE__ */ jsxs7("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
4589
+ /* @__PURE__ */ jsxs8("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
3990
4590
  "About",
3991
- /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
4591
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3992
4592
  ] }),
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: [
4593
+ /* @__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." }) }),
4594
+ /* @__PURE__ */ jsxs8("a", { href: "https://github.com/VanillaSkyAi/video", target: "_blank", rel: "noopener noreferrer", children: [
3995
4595
  "GitHub",
3996
- /* @__PURE__ */ jsx9("span", { "aria-hidden": "true", children: "\u2197" })
4596
+ /* @__PURE__ */ jsx11("span", { "aria-hidden": "true", children: "\u2197" })
3997
4597
  ] })
3998
4598
  ] })
3999
4599
  ]
4000
4600
  }
4001
4601
  )
4002
4602
  ] }),
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: [
4603
+ /* @__PURE__ */ jsx11("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs8("div", { className: "panel-inner", children: [
4604
+ /* @__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
4605
  "Show transcript",
4006
- /* @__PURE__ */ jsx9(ChevronUp, {})
4007
- ] }) : /* @__PURE__ */ jsxs7(
4606
+ /* @__PURE__ */ jsx11(ChevronUp, {})
4607
+ ] }) : /* @__PURE__ */ jsxs8(
4008
4608
  "div",
4009
4609
  {
4010
4610
  className: "line-row",
@@ -4017,44 +4617,44 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
4017
4617
  onFocusCapture: captionControls.onFocusCapture,
4018
4618
  onBlurCapture: captionControls.onBlurCapture,
4019
4619
  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: [
4620
+ captionsOn && line && /* @__PURE__ */ jsxs8("div", { className: "caption-actions", children: [
4621
+ /* @__PURE__ */ jsxs8("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
4022
4622
  captionsExpanded ? "Collapse" : "Expand",
4023
- /* @__PURE__ */ jsx9(ChevronUp, {})
4623
+ /* @__PURE__ */ jsx11(ChevronUp, {})
4024
4624
  ] }),
4025
- /* @__PURE__ */ jsx9("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
4625
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
4026
4626
  setCaptionsOn(false);
4027
4627
  setCaptionsExpanded(false);
4028
- }, children: /* @__PURE__ */ jsx9(Close, {}) })
4628
+ }, children: /* @__PURE__ */ jsx11(Close, {}) })
4029
4629
  ] }),
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}`)
4630
+ 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
4631
  ]
4032
4632
  }
4033
4633
  ) }) }),
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, {}) })
4634
+ showRecoveryNotice && chat.shownTurn && dismissedNoticeTurn !== chat.shownTurn.id && chat.warnings.includes(MEDIA_RECOVERY_NOTICE) && !chat.error && /* @__PURE__ */ jsxs8("div", { className: "recovery-notice", children: [
4635
+ /* @__PURE__ */ jsx11("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
4636
+ /* @__PURE__ */ jsx11("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx11(Close, {}) })
4037
4637
  ] }),
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 })
4638
+ (chat.error || listen.error) && /* @__PURE__ */ jsxs8("p", { className: "error", role: "status", children: [
4639
+ /* @__PURE__ */ jsx11(Warning, {}),
4640
+ /* @__PURE__ */ jsx11("span", { children: chat.error?.message ?? listen.error })
4041
4641
  ] }),
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) => {
4642
+ /* @__PURE__ */ jsxs8("div", { ref: composerRef, className: "conversation-composer", "data-editing": editing, ...controlEvents, children: [
4643
+ (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" }) }),
4644
+ /* @__PURE__ */ jsxs8("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
4045
4645
  event.preventDefault();
4046
4646
  ask(draft);
4047
4647
  }, children: [
4048
- transport && /* @__PURE__ */ jsx9("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
4648
+ transport && /* @__PURE__ */ jsx11("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
4049
4649
  listen.stop();
4050
4650
  setEditing(false);
4051
4651
  resumeAfterInput.current = false;
4052
4652
  inputRef.current?.blur();
4053
4653
  transport.action();
4054
4654
  }, 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(
4655
+ /* @__PURE__ */ jsx11(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
4656
+ /* @__PURE__ */ jsx11("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
4657
+ /* @__PURE__ */ jsx11(
4058
4658
  "textarea",
4059
4659
  {
4060
4660
  id: promptId,
@@ -4082,17 +4682,28 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
4082
4682
  }
4083
4683
  }
4084
4684
  ),
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, {}) })
4685
+ 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, {}) }),
4686
+ /* @__PURE__ */ jsx11("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx11(Send, {}) }),
4687
+ editing && /* @__PURE__ */ jsx11("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx11(Close, {}) })
4088
4688
  ] })
4089
4689
  ] }),
4090
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx9("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
4690
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx11("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
4091
4691
  ] }) })
4092
4692
  ]
4093
4693
  }
4094
4694
  );
4095
4695
  }
4696
+ function safeHomeUrl(value) {
4697
+ const candidate = value?.trim();
4698
+ if (!candidate || !/^(?:https?:\/\/|\/(?![/\\]))/i.test(candidate)) return void 0;
4699
+ try {
4700
+ const url = new URL(candidate, "https://vanillasky.local");
4701
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password) return void 0;
4702
+ return candidate.startsWith("/") ? `${url.pathname}${url.search}${url.hash}` : url.href;
4703
+ } catch {
4704
+ return void 0;
4705
+ }
4706
+ }
4096
4707
  export {
4097
4708
  VideoChat,
4098
4709
  VideoError,