@vanillaskyai/video 0.8.8 → 0.9.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 (175) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/PUBLIC-API.md +5 -8
  3. package/dist/{builtin-server-CUYPIDE7.js → builtin-server-HW6HGXCZ.js} +2 -2
  4. package/dist/{catalog-types-BIhSpOWK.d.ts → catalog-types-WTbLP6Jh.d.ts} +5 -1
  5. package/dist/chapter-title-YRLIQY3V.js +7 -0
  6. package/dist/check-runtime.js +6 -15
  7. package/dist/{chunk-2USVB3CY.js → chunk-3ZTEFJIR.js} +285 -178
  8. package/dist/chunk-4YM2M62S.js +13 -0
  9. package/dist/chunk-77VY4O7A.js +390 -0
  10. package/dist/{chunk-MMUXVA47.js → chunk-7M56IUUX.js} +9 -8
  11. package/dist/chunk-7NZOMTAL.js +63 -0
  12. package/dist/{chunk-2DOLF4QN.js → chunk-AUPC6MDK.js} +12 -14
  13. package/dist/{chunk-352ZZCET.js → chunk-EGVQODKU.js} +2 -2
  14. package/dist/chunk-EWWCQFTI.js +18 -0
  15. package/dist/{chunk-MXZSDGZQ.js → chunk-G7IZBBWF.js} +14 -9
  16. package/dist/{chunk-KT3M7KDL.js → chunk-HKUI5BTB.js} +3 -3
  17. package/dist/{chunk-LMG3GHLL.js → chunk-IQMYK5DX.js} +29 -3
  18. package/dist/chunk-LH4BPALA.js +564 -0
  19. package/dist/{chunk-OWBT45EN.js → chunk-MQILEEXC.js} +1 -1
  20. package/dist/chunk-MXEYPK7M.js +552 -0
  21. package/dist/{chunk-AITKH6QT.js → chunk-OR52UMSF.js} +13 -39
  22. package/dist/{chunk-5SLENAJW.js → chunk-PAJ44TFW.js} +1 -2
  23. package/dist/{chunk-L7QGGKJ2.js → chunk-PDQFQIQW.js} +1 -1
  24. package/dist/{chunk-WJ6FLKNC.js → chunk-R3XAOMKP.js} +14 -0
  25. package/dist/{chunk-2OKB27TG.js → chunk-RXTN2CW6.js} +2 -2
  26. package/dist/{chunk-XWWLKRNU.js → chunk-TVIU23OM.js} +38 -49
  27. package/dist/{chunk-224QNWRA.js → chunk-UDIODEDK.js} +1 -5
  28. package/dist/{chunk-3VRMKFM7.js → chunk-ZDJNFA52.js} +11 -24
  29. package/dist/cinema-media-YQHSIFDA.js +11 -0
  30. package/dist/cli.js +18 -9
  31. package/dist/comparison-RZMMHZR2.js +27 -0
  32. package/dist/compose-video-TBFTONUC.js +12 -0
  33. package/dist/editorial-timeline-OZX7OS6G.js +36 -0
  34. package/dist/{events-BQ6z264n.d.ts → events-B4YCc4vc.d.ts} +1 -1
  35. package/dist/focus-cards-TJ72BT7U.js +20 -0
  36. package/dist/index.d.ts +4 -6
  37. package/dist/index.js +3 -7
  38. package/dist/key-figure-GYENXKH3.js +20 -0
  39. package/dist/{kit-tQnCPMOM.d.ts → kit-DrRpdn0p.d.ts} +2 -2
  40. package/dist/mobile-message-UZUPIKHD.js +49 -0
  41. package/dist/{preload-media-LJXWKTWG.js → preload-media-CFTZIMZL.js} +1 -1
  42. package/dist/quote-EW5HQA6H.js +23 -0
  43. package/dist/react.d.ts +21 -11
  44. package/dist/react.js +590 -833
  45. package/dist/scene-video-backdrop-PCTARAAJ.js +7 -0
  46. package/dist/server.d.ts +7 -8
  47. package/dist/server.js +91 -74
  48. package/dist/{system-prompt-RRXIWDDD.js → system-prompt-SYRT5DWM.js} +1 -1
  49. package/dist/template-catalog.d.ts +257 -16
  50. package/dist/template-catalog.js +1 -1
  51. package/dist/templates.d.ts +5 -5
  52. package/dist/test.d.ts +3 -3
  53. package/dist/test.js +18 -18
  54. package/dist/text-stream-LD364KBI.js +9 -0
  55. package/dist/{types-DWFO6qca.d.ts → types-CdBmNTGD.d.ts} +1 -1
  56. package/dist/{types-CEh9eUFu.d.ts → types-DdZw4GRQ.d.ts} +17 -48
  57. package/dist/validate-4OUD2CLX.js +10 -0
  58. package/docs/architecture.md +1 -1
  59. package/docs/concepts.md +1 -1
  60. package/docs/custom-templates.md +13 -28
  61. package/docs/customization.md +11 -59
  62. package/docs/getting-started.md +2 -2
  63. package/docs/immersive-interface.md +3 -4
  64. package/docs/maintainers/cinematic-migration.md +34 -0
  65. package/docs/media-and-audio.md +14 -14
  66. package/docs/motion-and-effects.md +6 -14
  67. package/docs/persistence.md +3 -3
  68. package/docs/production.md +11 -9
  69. package/docs/provider-integration.md +9 -16
  70. package/docs/reference/design-system.html +1 -1
  71. package/docs/reference/protocol.md +14 -4
  72. package/docs/testing.md +1 -1
  73. package/package.json +6 -3
  74. package/registry/items/backgrounds.json +1 -1
  75. package/registry/items/chapterTitle.json +80 -0
  76. package/registry/items/cinemaMedia.json +136 -0
  77. package/registry/items/comparison.json +101 -0
  78. package/registry/items/editorialTimeline.json +105 -0
  79. package/registry/items/focusCards.json +90 -0
  80. package/registry/items/keyFigure.json +97 -0
  81. package/registry/items/mobileMessage.json +153 -0
  82. package/registry/items/quote.json +94 -0
  83. package/registry/items/template-context.json +1 -1
  84. package/registry/items/theme.json +1 -1
  85. package/starters/video-chat/README.md +24 -26
  86. package/starters/video-chat/package.json +1 -1
  87. package/starters/video-chat/providers/video.ts +6 -5
  88. package/starters/video-chat/stock.ts +35 -151
  89. package/styles/fonts/OFL.txt +93 -0
  90. package/styles/fonts/manifest.json +66 -0
  91. package/styles/fonts/roboto-v51-cyrillic-ext.woff2 +0 -0
  92. package/styles/fonts/roboto-v51-cyrillic.woff2 +0 -0
  93. package/styles/fonts/roboto-v51-greek-ext.woff2 +0 -0
  94. package/styles/fonts/roboto-v51-greek.woff2 +0 -0
  95. package/styles/fonts/roboto-v51-latin-ext.woff2 +0 -0
  96. package/styles/fonts/roboto-v51-latin.woff2 +0 -0
  97. package/styles/fonts/roboto-v51-math.woff2 +0 -0
  98. package/styles/fonts/roboto-v51-symbols.woff2 +0 -0
  99. package/styles/fonts/roboto-v51-vietnamese.woff2 +0 -0
  100. package/styles/fonts/roboto.css +91 -0
  101. package/styles/video-chat.css +30 -22
  102. package/dist/bg-confetti-HITCGLGD.js +0 -89
  103. package/dist/bg-emoji-VU4UZYZN.js +0 -150
  104. package/dist/bg-media-L34PDQXJ.js +0 -106
  105. package/dist/brand-message-FUJ4STFO.js +0 -256
  106. package/dist/chart-bar-EGPHJATL.js +0 -310
  107. package/dist/chart-counter-5FFWQNZO.js +0 -306
  108. package/dist/chart-progress-ring-JMXQLMLC.js +0 -302
  109. package/dist/chunk-4UNKZEAT.js +0 -3575
  110. package/dist/chunk-5CDAM24P.js +0 -31
  111. package/dist/chunk-AOWSXU2K.js +0 -18
  112. package/dist/chunk-CJ7F2CE6.js +0 -104
  113. package/dist/chunk-FRN6WKHA.js +0 -185
  114. package/dist/chunk-G5JPHBX7.js +0 -336
  115. package/dist/chunk-L5BTTCZV.js +0 -97
  116. package/dist/chunk-P22HHFDK.js +0 -43
  117. package/dist/chunk-PP3MCPN4.js +0 -1998
  118. package/dist/chunk-QAAO7OAY.js +0 -19
  119. package/dist/chunk-S7HFLYNN.js +0 -35
  120. package/dist/chunk-W3VDIBGR.js +0 -217
  121. package/dist/chunk-XGRA2MUV.js +0 -242
  122. package/dist/chunk-XTOXFMLM.js +0 -105
  123. package/dist/chunk-YJJC4N4D.js +0 -761
  124. package/dist/compose-video-2NFH4DZY.js +0 -13
  125. package/dist/cta-logo-YR7LYUR5.js +0 -212
  126. package/dist/cta-media-DQJBKUO2.js +0 -221
  127. package/dist/incoming-call-54KQ7O5A.js +0 -323
  128. package/dist/infographic-before-after-ROS52GOW.js +0 -460
  129. package/dist/infographic-feature-list-E7MGUDAN.js +0 -272
  130. package/dist/infographic-problem-solution-3S6NGO5N.js +0 -343
  131. package/dist/infographic-stat-row-SYB7AB7H.js +0 -247
  132. package/dist/infographic-steps-KCE6JWE2.js +0 -406
  133. package/dist/prompt-input-MHX4O42G.js +0 -239
  134. package/dist/reaction-SLEGR3BE.js +0 -122
  135. package/dist/scene-video-backdrop-XFPX4O3I.js +0 -10
  136. package/dist/showcase-code-2HLAVKBM.js +0 -357
  137. package/dist/showcase-phone-Q4HEO4LH.js +0 -278
  138. package/dist/showcase-terminal-DL45LUFG.js +0 -312
  139. package/dist/showcase-web-2QZO73FZ.js +0 -396
  140. package/dist/social-conversation-75SD2637.js +0 -695
  141. package/dist/social-milestone-GEFUQSBF.js +0 -296
  142. package/dist/social-notification-FJ5VHVLK.js +0 -247
  143. package/dist/social-review-stack-KXQCKFRP.js +0 -242
  144. package/dist/social-testimonial-JZ7CHC7S.js +0 -287
  145. package/dist/social-tweet-UCDXM25F.js +0 -298
  146. package/dist/text-stream-54LXABJW.js +0 -10
  147. package/dist/validate-T7GBU2YF.js +0 -8
  148. package/registry/items/barChart.json +0 -245
  149. package/registry/items/beforeAfter.json +0 -180
  150. package/registry/items/bigNumber.json +0 -232
  151. package/registry/items/brandMessage.json +0 -164
  152. package/registry/items/cardList.json +0 -214
  153. package/registry/items/chatMessenger.json +0 -151
  154. package/registry/items/chatWhatsapp.json +0 -151
  155. package/registry/items/codeEditor.json +0 -191
  156. package/registry/items/confetti.json +0 -107
  157. package/registry/items/ctaLogo.json +0 -167
  158. package/registry/items/ctaMedia.json +0 -200
  159. package/registry/items/emojiBurst.json +0 -101
  160. package/registry/items/incomingCall.json +0 -177
  161. package/registry/items/media.json +0 -187
  162. package/registry/items/milestone.json +0 -212
  163. package/registry/items/notification.json +0 -189
  164. package/registry/items/phoneMockup.json +0 -261
  165. package/registry/items/problemSolution.json +0 -193
  166. package/registry/items/progressRing.json +0 -206
  167. package/registry/items/promptInput.json +0 -155
  168. package/registry/items/reaction.json +0 -176
  169. package/registry/items/reviewStack.json +0 -231
  170. package/registry/items/steps.json +0 -215
  171. package/registry/items/terminal.json +0 -219
  172. package/registry/items/testimonial.json +0 -171
  173. package/registry/items/tripleStats.json +0 -235
  174. package/registry/items/tweet.json +0 -210
  175. package/registry/items/webMockup.json +0 -280
package/dist/react.js CHANGED
@@ -1,51 +1,55 @@
1
1
  import {
2
- getSceneDuration
2
+ TitleSceneTemplate
3
+ } from "./chunk-EWWCQFTI.js";
4
+ import "./chunk-4YM2M62S.js";
5
+ import {
6
+ getSceneDuration,
7
+ getSceneDurationBounds
3
8
  } from "./chunk-ZD2VTUYR.js";
4
9
  import {
5
10
  MEDIA_RECOVERY_NOTICE,
6
11
  decodeVideoSse,
7
12
  sanitizeVideoChatMedia,
8
13
  withDeadline
9
- } from "./chunk-KT3M7KDL.js";
14
+ } from "./chunk-HKUI5BTB.js";
10
15
  import {
11
16
  applyVideoEvent,
12
17
  checksumVideo,
13
18
  createVideoEventFactory,
14
19
  createVideoState
15
- } from "./chunk-2OKB27TG.js";
20
+ } from "./chunk-RXTN2CW6.js";
16
21
  import {
17
22
  safePublicDiagnostic
18
- } from "./chunk-MMUXVA47.js";
23
+ } from "./chunk-7M56IUUX.js";
19
24
  import {
20
25
  parseVideo
21
- } from "./chunk-2DOLF4QN.js";
26
+ } from "./chunk-AUPC6MDK.js";
22
27
  import {
23
- VIDEO_SCHEMA_VERSION
24
- } from "./chunk-AOWSXU2K.js";
28
+ VIDEO_SCHEMA_VERSION,
29
+ validateNarrationGroups
30
+ } from "./chunk-7NZOMTAL.js";
25
31
  import {
26
32
  getBuiltinTemplateMetadata
27
- } from "./chunk-4UNKZEAT.js";
33
+ } from "./chunk-LH4BPALA.js";
28
34
  import "./chunk-3O7OMMMF.js";
29
35
  import {
30
36
  assertTemplateTransitionMetadata
31
37
  } from "./chunk-2E6T633S.js";
32
38
  import {
39
+ MountedReadinessContext,
33
40
  VideoFrame,
34
- getDimensions
35
- } from "./chunk-2USVB3CY.js";
36
- import "./chunk-224QNWRA.js";
37
- import "./chunk-5CDAM24P.js";
38
- import {
39
- fontStack
40
- } from "./chunk-XWWLKRNU.js";
41
+ getDimensions,
42
+ sceneReadinessKey
43
+ } from "./chunk-3ZTEFJIR.js";
44
+ import "./chunk-UDIODEDK.js";
45
+ import "./chunk-TVIU23OM.js";
41
46
  import {
42
47
  getVideoDuration,
43
48
  resolveVideoTimeline
44
49
  } from "./chunk-SPVTJH3F.js";
45
50
  import {
46
- resolveVideoBrand
47
- } from "./chunk-XGRA2MUV.js";
48
- import "./chunk-WJ6FLKNC.js";
51
+ markExternalVideoBackdropTemplate
52
+ } from "./chunk-R3XAOMKP.js";
49
53
  import "./chunk-73NTSFFI.js";
50
54
 
51
55
  // src/player/video-player.tsx
@@ -89,582 +93,96 @@ import { createElement } from "react";
89
93
 
90
94
  // src/visual-system/catalog/builtin-loaders.generated.ts
91
95
  var GENERATED_BUILTIN_TEMPLATE_LOADERS = {
92
- "media": () => import("./bg-media-L34PDQXJ.js").then((module) => ({ default: module.BgMediaTemplate })),
93
- "reaction": () => import("./reaction-SLEGR3BE.js").then((module) => ({ default: module.ReactionTemplate })),
94
- "confetti": () => import("./bg-confetti-HITCGLGD.js").then((module) => ({ default: module.BgConfettiTemplate })),
95
- "emojiBurst": () => import("./bg-emoji-VU4UZYZN.js").then((module) => ({ default: module.BgEmojiTemplate })),
96
- "bigNumber": () => import("./chart-counter-5FFWQNZO.js").then((module) => ({ default: module.ChartCounterTemplate })),
97
- "barChart": () => import("./chart-bar-EGPHJATL.js").then((module) => ({ default: module.ChartBarTemplate })),
98
- "progressRing": () => import("./chart-progress-ring-JMXQLMLC.js").then((module) => ({ default: module.ChartProgressRingTemplate })),
99
- "phoneMockup": () => import("./showcase-phone-Q4HEO4LH.js").then((module) => ({ default: module.ShowcasePhoneTemplate })),
100
- "webMockup": () => import("./showcase-web-2QZO73FZ.js").then((module) => ({ default: module.ShowcaseWebTemplate })),
101
- "codeEditor": () => import("./showcase-code-2HLAVKBM.js").then((module) => ({ default: module.ShowcaseCodeTemplate })),
102
- "terminal": () => import("./showcase-terminal-DL45LUFG.js").then((module) => ({ default: module.ShowcaseTerminalTemplate })),
103
- "tweet": () => import("./social-tweet-UCDXM25F.js").then((module) => ({ default: module.SocialTweetTemplate })),
104
- "notification": () => import("./social-notification-FJ5VHVLK.js").then((module) => ({ default: module.SocialNotificationTemplate })),
105
- "chatMessenger": () => import("./social-conversation-75SD2637.js").then((module) => ({ default: module.SocialConversationTemplate })),
106
- "chatWhatsapp": () => import("./social-conversation-75SD2637.js").then((module) => ({ default: module.SocialConversationTemplate })),
107
- "milestone": () => import("./social-milestone-GEFUQSBF.js").then((module) => ({ default: module.SocialMilestoneTemplate })),
108
- "reviewStack": () => import("./social-review-stack-KXQCKFRP.js").then((module) => ({ default: module.SocialReviewStackTemplate })),
109
- "testimonial": () => import("./social-testimonial-JZ7CHC7S.js").then((module) => ({ default: module.SocialTestimonialTemplate })),
110
- "incomingCall": () => import("./incoming-call-54KQ7O5A.js").then((module) => ({ default: module.IncomingCallTemplate })),
111
- "brandMessage": () => import("./brand-message-FUJ4STFO.js").then((module) => ({ default: module.BrandMessageTemplate })),
112
- "promptInput": () => import("./prompt-input-MHX4O42G.js").then((module) => ({ default: module.PromptInputTemplate })),
113
- "beforeAfter": () => import("./infographic-before-after-ROS52GOW.js").then((module) => ({ default: module.InfographicBeforeAfterTemplate })),
114
- "tripleStats": () => import("./infographic-stat-row-SYB7AB7H.js").then((module) => ({ default: module.InfographicStatRowTemplate })),
115
- "problemSolution": () => import("./infographic-problem-solution-3S6NGO5N.js").then((module) => ({ default: module.InfographicProblemSolutionTemplate })),
116
- "cardList": () => import("./infographic-feature-list-E7MGUDAN.js").then((module) => ({ default: module.InfographicFeatureListTemplate })),
117
- "steps": () => import("./infographic-steps-KCE6JWE2.js").then((module) => ({ default: module.InfographicStepsTemplate })),
118
- "ctaLogo": () => import("./cta-logo-YR7LYUR5.js").then((module) => ({ default: module.CtaLogoTemplate })),
119
- "ctaMedia": () => import("./cta-media-DQJBKUO2.js").then((module) => ({ default: module.CtaMediaTemplate }))
96
+ "cinemaMedia": () => import("./cinema-media-YQHSIFDA.js").then((module) => ({ default: module.MediaSceneTemplate })),
97
+ "chapterTitle": () => import("./chapter-title-YRLIQY3V.js").then((module) => ({ default: module.TitleSceneTemplate })),
98
+ "focusCards": () => import("./focus-cards-TJ72BT7U.js").then((module) => ({ default: module.CardsSceneTemplate })),
99
+ "editorialTimeline": () => import("./editorial-timeline-OZX7OS6G.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
+ "mobileMessage": () => import("./mobile-message-UZUPIKHD.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
+ "comparison": () => import("./comparison-RZMMHZR2.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
+ "quote": () => import("./quote-EW5HQA6H.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
+ "keyFigure": () => import("./key-figure-GYENXKH3.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
120
104
  };
121
105
 
122
106
  // src/visual-system/catalog/builtin-player.generated.ts
123
107
  var GENERATED_BUILTIN_PLAYER_TEMPLATES = [
124
108
  {
125
- "id": "media",
126
- "defaults": {
127
- "texts": "Make an impact.",
128
- "mediaUrl": "",
129
- "mediaKeyword": "",
130
- "mediaType": "auto",
131
- "mediaPoster": "",
132
- "mediaPosition": "center",
133
- "mediaTreatment": "cinematic",
134
- "confetti": false
135
- },
136
- "usesGlobalTransition": true,
137
- "transitionTiming": {
138
- "entryReadyProgress": 0.2,
139
- "holdProgress": 0.7
140
- }
141
- },
142
- {
143
- "id": "reaction",
109
+ "id": "cinemaMedia",
144
110
  "defaults": {
145
- "texts": "Still editing manually?",
146
- "reactionTag": "manual",
147
111
  "mediaUrl": "",
148
112
  "mediaType": "video",
149
113
  "mediaPoster": ""
150
114
  },
151
- "usesGlobalTransition": true,
152
- "transitionTiming": {
153
- "entryReadyProgress": 0.2,
154
- "holdProgress": 0.7
155
- }
156
- },
157
- {
158
- "id": "confetti",
159
- "defaults": {
160
- "texts": "Celebrate."
161
- },
162
- "usesGlobalTransition": true,
163
- "transitionTiming": {
164
- "entryReadyProgress": 0.2,
165
- "holdProgress": 0.7
166
- }
115
+ "usesGlobalTransition": false
167
116
  },
168
117
  {
169
- "id": "emojiBurst",
118
+ "id": "chapterTitle",
170
119
  "defaults": {
171
- "texts": "Let's go!"
120
+ "title": "A different perspective"
172
121
  },
173
- "usesGlobalTransition": true,
174
- "transitionTiming": {
175
- "entryReadyProgress": 0.2,
176
- "holdProgress": 0.7
177
- }
122
+ "usesGlobalTransition": false
178
123
  },
179
124
  {
180
- "id": "bigNumber",
125
+ "id": "focusCards",
181
126
  "defaults": {
182
- "texts": "Our biggest milestone yet.",
183
- "value": 1e3,
184
- "confetti": false,
185
- "label": "Total users",
186
- "prefix": "",
187
- "unit": "",
188
- "mediaUrl": "",
189
- "mediaKeyword": "",
190
- "mediaType": "auto",
191
- "mediaPoster": "",
192
- "mediaPosition": "center",
193
- "mediaTreatment": "cinematic"
127
+ "items": [
128
+ "Listen closely",
129
+ "Notice the pattern",
130
+ "Make room for change"
131
+ ]
194
132
  },
195
- "usesGlobalTransition": true,
196
- "transitionTiming": {
197
- "entryReadyProgress": 0.2,
198
- "holdProgress": 0.7
199
- }
133
+ "usesGlobalTransition": false
200
134
  },
201
135
  {
202
- "id": "barChart",
136
+ "id": "editorialTimeline",
203
137
  "defaults": {
204
- "texts": "Revenue up 300%.",
205
- "bars": [
206
- {
207
- "label": "Q1",
208
- "value": 42
209
- },
138
+ "events": [
210
139
  {
211
- "label": "Q2",
212
- "value": 58
140
+ "label": "Observe"
213
141
  },
214
142
  {
215
- "label": "Q3",
216
- "value": 76
143
+ "label": "Understand"
217
144
  },
218
145
  {
219
- "label": "Q4",
220
- "value": 91
146
+ "label": "Act"
221
147
  }
222
- ],
223
- "mediaUrl": "",
224
- "mediaKeyword": "",
225
- "mediaType": "auto",
226
- "mediaPoster": "",
227
- "mediaPosition": "center",
228
- "mediaTreatment": "cinematic"
229
- },
230
- "usesGlobalTransition": true,
231
- "transitionTiming": {
232
- "entryReadyProgress": 0.2,
233
- "holdProgress": 0.7
234
- }
235
- },
236
- {
237
- "id": "progressRing",
238
- "defaults": {
239
- "texts": "Almost there.",
240
- "value": 75,
241
- "label": "Complete",
242
- "unit": "%",
243
- "mediaUrl": "",
244
- "mediaKeyword": "",
245
- "mediaType": "auto",
246
- "mediaPoster": "",
247
- "mediaPosition": "center",
248
- "mediaTreatment": "cinematic"
249
- },
250
- "usesGlobalTransition": true,
251
- "transitionTiming": {
252
- "entryReadyProgress": 0.2,
253
- "holdProgress": 0.7
254
- }
255
- },
256
- {
257
- "id": "phoneMockup",
258
- "defaults": {
259
- "texts": "See it in action.",
260
- "screenMediaUrl": "",
261
- "screen1Url": "",
262
- "screen2Url": "",
263
- "screenFit": "cover",
264
- "screenFocusX": 50,
265
- "screenFocusY": 50,
266
- "screenMotion": "pushIn",
267
- "screenCalloutText": "",
268
- "screenCalloutX": 70,
269
- "screenCalloutY": 35,
270
- "mediaUrl": "",
271
- "mediaKeyword": "",
272
- "mediaType": "auto",
273
- "mediaPoster": "",
274
- "mediaPosition": "center",
275
- "mediaTreatment": "cinematic"
276
- },
277
- "usesGlobalTransition": true,
278
- "transitionTiming": {
279
- "entryReadyProgress": 0.2,
280
- "holdProgress": 0.7
281
- }
282
- },
283
- {
284
- "id": "webMockup",
285
- "defaults": {
286
- "frame": "browser",
287
- "texts": "See it in action.",
288
- "screenMediaUrl": "",
289
- "screen1Url": "",
290
- "screen2Url": "",
291
- "screenFit": "cover",
292
- "screenFocusX": 50,
293
- "screenFocusY": 50,
294
- "screenMotion": "pushIn",
295
- "screenCalloutText": "",
296
- "screenCalloutX": 70,
297
- "screenCalloutY": 35,
298
- "addressBarUrl": "",
299
- "mediaUrl": "",
300
- "mediaKeyword": "",
301
- "mediaType": "auto",
302
- "mediaPoster": "",
303
- "mediaPosition": "center",
304
- "mediaTreatment": "cinematic"
305
- },
306
- "usesGlobalTransition": true,
307
- "transitionTiming": {
308
- "entryReadyProgress": 0.2,
309
- "holdProgress": 0.7
310
- }
311
- },
312
- {
313
- "id": "codeEditor",
314
- "defaults": {
315
- "texts": "Simple as this.",
316
- "code": 'import { getVideoDuration } from "@vanillaskyai/video";\nimport type { Video } from "@vanillaskyai/video";\n\nexport function duration(video: Video) {\n return getVideoDuration(video);\n}',
317
- "filename": "app.ts",
318
- "mediaUrl": "",
319
- "mediaKeyword": "",
320
- "mediaType": "auto",
321
- "mediaPoster": "",
322
- "mediaPosition": "center",
323
- "mediaTreatment": "cinematic"
324
- },
325
- "usesGlobalTransition": true,
326
- "transitionTiming": {
327
- "entryReadyProgress": 0.2,
328
- "holdProgress": 0.7
329
- }
330
- },
331
- {
332
- "id": "terminal",
333
- "defaults": {
334
- "texts": "Ship it.",
335
- "command": "npx vanillasky templates check",
336
- "output": [],
337
- "promptPrefix": "$",
338
- "mediaUrl": "",
339
- "mediaKeyword": "",
340
- "mediaType": "auto",
341
- "mediaPoster": "",
342
- "mediaPosition": "center",
343
- "mediaTreatment": "cinematic"
344
- },
345
- "usesGlobalTransition": true,
346
- "transitionTiming": {
347
- "entryReadyProgress": 0.2,
348
- "holdProgress": 0.7
349
- }
350
- },
351
- {
352
- "id": "tweet",
353
- "defaults": {
354
- "authorName": "Your brand",
355
- "authorHandle": "",
356
- "authorVerified": false,
357
- "message": "Just shipped the new feature \u{1F680}",
358
- "likes": 0,
359
- "replies": 0,
360
- "mediaUrl": "",
361
- "mediaKeyword": "",
362
- "mediaType": "auto",
363
- "mediaPoster": "",
364
- "mediaPosition": "center",
365
- "mediaTreatment": "cinematic"
366
- },
367
- "usesGlobalTransition": true,
368
- "transitionTiming": {
369
- "entryReadyProgress": 0.2,
370
- "holdProgress": 0.7
371
- }
372
- },
373
- {
374
- "id": "notification",
375
- "defaults": {
376
- "appName": "Reminder",
377
- "appIcon": "\u{1F514}",
378
- "message": "Believe in your dreams, they will lead you.",
379
- "mediaUrl": "",
380
- "mediaKeyword": "",
381
- "mediaType": "auto",
382
- "mediaPoster": "",
383
- "mediaPosition": "center",
384
- "mediaTreatment": "cinematic"
385
- },
386
- "usesGlobalTransition": true,
387
- "transitionTiming": {
388
- "entryReadyProgress": 0.35,
389
- "holdProgress": 0.7
390
- }
391
- },
392
- {
393
- "id": "chatMessenger",
394
- "defaults": {
395
- "msg1": "Have you tried it yet?",
396
- "msg2": "Yes \u2014 it saves me hours|out",
397
- "msg3": "Okay, send me the link",
398
- "msg4": "",
399
- "dateChip1": "",
400
- "dateChip2": "",
401
- "msg5": "",
402
- "theme": "messenger"
403
- },
404
- "usesGlobalTransition": false
405
- },
406
- {
407
- "id": "chatWhatsapp",
408
- "defaults": {
409
- "msg1": "Have you tried it yet?",
410
- "msg2": "Yes \u2014 it saves me hours|out",
411
- "msg3": "Okay, send me the link",
412
- "msg4": "",
413
- "dateChip1": "",
414
- "dateChip2": "",
415
- "msg5": "",
416
- "theme": "whatsapp"
417
- },
418
- "usesGlobalTransition": false
419
- },
420
- {
421
- "id": "milestone",
422
- "defaults": {
423
- "label": "Followers",
424
- "targetNumber": 1e4,
425
- "startNumber": 0,
426
- "badgeText": "",
427
- "badgeEmoji": "\u{1F389}",
428
- "mediaUrl": "",
429
- "mediaKeyword": "",
430
- "mediaType": "auto",
431
- "mediaPoster": "",
432
- "mediaPosition": "center",
433
- "mediaTreatment": "cinematic"
434
- },
435
- "usesGlobalTransition": false
436
- },
437
- {
438
- "id": "reviewStack",
439
- "defaults": {
440
- "review1Title": "Life changing app",
441
- "review1Body": "",
442
- "review1Author": "",
443
- "review2Title": "Best in class",
444
- "review2Body": "",
445
- "review2Author": "",
446
- "review3Title": "Exceeded expectations",
447
- "review3Body": "",
448
- "review3Author": "",
449
- "mediaUrl": "",
450
- "mediaKeyword": "",
451
- "mediaType": "auto",
452
- "mediaPoster": "",
453
- "mediaPosition": "center",
454
- "mediaTreatment": "cinematic"
455
- },
456
- "usesGlobalTransition": false
457
- },
458
- {
459
- "id": "testimonial",
460
- "defaults": {
461
- "quote": "Best decision I made this year. Our conversion rate doubled in the first week.",
462
- "authorName": "Jessica Torres",
463
- "authorRole": "",
464
- "mediaUrl": "",
465
- "mediaKeyword": "",
466
- "mediaType": "auto",
467
- "mediaPoster": "",
468
- "mediaPosition": "center",
469
- "mediaTreatment": "cinematic"
470
- },
471
- "usesGlobalTransition": false
472
- },
473
- {
474
- "id": "incomingCall",
475
- "defaults": {
476
- "callerName": "Your brand",
477
- "subtitle": "is calling....",
478
- "declineLabel": "Decline",
479
- "acceptLabel": "Accept",
480
- "mediaUrl": "",
481
- "mediaKeyword": "",
482
- "mediaType": "auto",
483
- "mediaPoster": "",
484
- "mediaPosition": "center",
485
- "mediaTreatment": "cinematic"
148
+ ]
486
149
  },
487
150
  "usesGlobalTransition": false
488
151
  },
489
152
  {
490
- "id": "brandMessage",
153
+ "id": "mobileMessage",
491
154
  "defaults": {
492
- "message": "We built this for you.",
493
155
  "mediaUrl": "",
494
- "mediaKeyword": "",
495
- "mediaType": "auto",
156
+ "mediaType": "video",
496
157
  "mediaPoster": "",
497
- "mediaPosition": "center",
498
- "mediaTreatment": "cinematic"
158
+ "message": "Can we talk?",
159
+ "app": "Messages"
499
160
  },
500
161
  "usesGlobalTransition": false
501
162
  },
502
163
  {
503
- "id": "promptInput",
164
+ "id": "comparison",
504
165
  "defaults": {
505
- "promptText": "Make a launch video for our app",
506
- "mediaUrl": "",
507
- "mediaKeyword": "",
508
- "mediaType": "auto",
509
- "mediaPoster": "",
510
- "mediaPosition": "center",
511
- "mediaTreatment": "cinematic"
166
+ "leftText": "More distractions",
167
+ "rightText": "More room to think"
512
168
  },
513
169
  "usesGlobalTransition": false
514
170
  },
515
171
  {
516
- "id": "beforeAfter",
517
- "defaults": {
518
- "problemLabel": "BEFORE",
519
- "problemHeadline": "Your calendar today.",
520
- "solutionLabel": "AFTER",
521
- "solutionHeadline": "Calmly organized.",
522
- "problemEmojis": [
523
- "\u{1F4C5}",
524
- "\u{1F630}",
525
- "\u{1F4BC}",
526
- "\u{1F4CA}",
527
- "\u23F0",
528
- "\u{1F4AC}",
529
- "\u{1F4DE}",
530
- "\u{1F514}"
531
- ],
532
- "solutionEmojis": [
533
- "\u2728",
534
- "\u{1F4CB}",
535
- "\u2705",
536
- "\u{1F3AF}"
537
- ],
538
- "showEmojis": true
539
- },
172
+ "id": "quote",
173
+ "defaults": {},
540
174
  "usesGlobalTransition": false
541
175
  },
542
176
  {
543
- "id": "tripleStats",
544
- "defaults": {
545
- "texts": "By the numbers.",
546
- "stat1Value": "10K",
547
- "stat1Label": "Users",
548
- "stat2Value": "99.9%",
549
- "stat2Label": "Uptime",
550
- "stat3Value": "<50ms",
551
- "stat3Label": "Latency",
552
- "mediaUrl": "",
553
- "mediaKeyword": "",
554
- "mediaType": "auto",
555
- "mediaPoster": "",
556
- "mediaPosition": "center",
557
- "mediaTreatment": "cinematic"
558
- },
559
- "usesGlobalTransition": true,
560
- "transitionTiming": {
561
- "entryReadyProgress": 0.2,
562
- "holdProgress": 0.7
563
- }
564
- },
565
- {
566
- "id": "problemSolution",
567
- "defaults": {
568
- "problemLabel": "THE PROBLEM",
569
- "problemText": "Teams waste 40% of time in meetings",
570
- "solutionLabel": "THE SOLUTION",
571
- "solutionText": "AI that summarizes in seconds",
572
- "mediaUrl": "",
573
- "mediaKeyword": "",
574
- "mediaType": "auto",
575
- "mediaPoster": "",
576
- "mediaPosition": "center",
577
- "mediaTreatment": "cinematic"
578
- },
177
+ "id": "keyFigure",
178
+ "defaults": {},
579
179
  "usesGlobalTransition": false
580
- },
581
- {
582
- "id": "cardList",
583
- "defaults": {
584
- "texts": "What you get.",
585
- "items": [
586
- "Automate your savings on energy bills",
587
- "Cleaner energy without raising your bill",
588
- "Same account and service guaranteed"
589
- ],
590
- "itemEmojis": [],
591
- "mediaUrl": "",
592
- "mediaKeyword": "",
593
- "mediaType": "auto",
594
- "mediaPoster": "",
595
- "mediaPosition": "center",
596
- "mediaTreatment": "cinematic"
597
- },
598
- "usesGlobalTransition": true,
599
- "transitionTiming": {
600
- "entryReadyProgress": 0.2,
601
- "holdProgress": 0.7
602
- }
603
- },
604
- {
605
- "id": "steps",
606
- "defaults": {
607
- "texts": "How it works.",
608
- "steps": [
609
- "Describe",
610
- "Preview",
611
- "Export"
612
- ],
613
- "stepEmojis": [],
614
- "mediaUrl": "",
615
- "mediaKeyword": "",
616
- "mediaType": "auto",
617
- "mediaPoster": "",
618
- "mediaPosition": "center",
619
- "mediaTreatment": "cinematic"
620
- },
621
- "usesGlobalTransition": true,
622
- "transitionTiming": {
623
- "entryReadyProgress": 0.2,
624
- "holdProgress": 0.7
625
- }
626
- },
627
- {
628
- "id": "ctaLogo",
629
- "defaults": {
630
- "url": "",
631
- "cta": "",
632
- "mediaUrl": "",
633
- "mediaKeyword": "",
634
- "mediaType": "auto",
635
- "mediaPoster": "",
636
- "mediaPosition": "center",
637
- "mediaTreatment": "cinematic"
638
- },
639
- "usesGlobalTransition": true,
640
- "transitionTiming": {
641
- "entryReadyProgress": 0.2,
642
- "holdProgress": 0.7
643
- }
644
- },
645
- {
646
- "id": "ctaMedia",
647
- "defaults": {
648
- "headline": "Make every moment count",
649
- "url": "",
650
- "cta": "",
651
- "mediaUrl": "",
652
- "mediaKeyword": "",
653
- "mediaType": "auto",
654
- "mediaPoster": "",
655
- "mediaPosition": "center",
656
- "mediaTreatment": "cinematic"
657
- },
658
- "usesGlobalTransition": true,
659
- "transitionTiming": {
660
- "entryReadyProgress": 0.2,
661
- "holdProgress": 0.7
662
- }
663
180
  }
664
181
  ];
182
+ var GENERATED_BUILTIN_VIDEO_BACKDROP_IDS = ["cinemaMedia", "mobileMessage"];
665
183
 
666
184
  // src/visual-system/catalog/builtin-player.ts
667
- function createPreloadableBuiltinTemplate(loader) {
185
+ function createPreloadableBuiltinTemplate(loader, mediaBackdrop) {
668
186
  let loaded;
669
187
  let failure;
670
188
  let pending;
@@ -687,7 +205,8 @@ function createPreloadableBuiltinTemplate(loader) {
687
205
  if (failure) throw failure;
688
206
  throw preload();
689
207
  };
690
- return { component, preload };
208
+ const template = { component, preload };
209
+ return mediaBackdrop ? markExternalVideoBackdropTemplate(template) : template;
691
210
  }
692
211
  function freezeValue(value) {
693
212
  if (value && typeof value === "object" && !Object.isFrozen(value)) {
@@ -699,7 +218,7 @@ function freezeValue(value) {
699
218
  var preloadableById = new Map(
700
219
  GENERATED_BUILTIN_PLAYER_TEMPLATES.map(({ id }) => [
701
220
  id,
702
- createPreloadableBuiltinTemplate(GENERATED_BUILTIN_TEMPLATE_LOADERS[id])
221
+ createPreloadableBuiltinTemplate(GENERATED_BUILTIN_TEMPLATE_LOADERS[id], GENERATED_BUILTIN_VIDEO_BACKDROP_IDS.includes(id))
703
222
  ])
704
223
  );
705
224
  var builtinPlayerTemplates = Object.freeze(
@@ -716,7 +235,7 @@ function preloadBuiltinTemplate(id) {
716
235
 
717
236
  // src/player/warm-scene-media.ts
718
237
  function warmSceneMedia(variables) {
719
- void import("./preload-media-LJXWKTWG.js").then((module) => module.preloadSceneMedia(variables)).catch(() => {
238
+ void import("./preload-media-CFTZIMZL.js").then((module) => module.preloadSceneMedia(variables)).catch(() => {
720
239
  });
721
240
  }
722
241
 
@@ -817,8 +336,6 @@ function GenerationCover({
817
336
  onStart
818
337
  }) {
819
338
  if (!visible) return null;
820
- const background = config?.style.brand.background;
821
- const backgroundCss = background?.type === "solid" ? background.color : `linear-gradient(135deg, ${background?.colors[0] ?? "#8711C1"}, ${background?.colors[1] ?? "#2167E3"})`;
822
339
  return /* @__PURE__ */ jsxs(
823
340
  "div",
824
341
  {
@@ -832,9 +349,9 @@ function GenerationCover({
832
349
  placeItems: "center",
833
350
  padding: "12%",
834
351
  textAlign: "center",
835
- color: config?.style.brand.colors.foreground ?? "#ffffff",
836
- background: backgroundCss,
837
- fontFamily: fontStack(config?.style.brand.font)
352
+ color: "#fff",
353
+ background: "#000",
354
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Helvetica Neue", Roboto, Arial, sans-serif'
838
355
  },
839
356
  children: [
840
357
  /* @__PURE__ */ jsxs("div", { style: { position: "absolute", top: "28%", left: "10%", right: "10%" }, children: [
@@ -1013,6 +530,8 @@ function usePlaybackClock({
1013
530
  audioRef,
1014
531
  loopRef,
1015
532
  sceneIndexRef,
533
+ visualReadyRef,
534
+ posterBridgeKeysRef,
1016
535
  callbacksRef,
1017
536
  setCurrentTime,
1018
537
  setIsPlaying
@@ -1020,6 +539,16 @@ function usePlaybackClock({
1020
539
  useEffect(() => {
1021
540
  if (!isPlaying) return;
1022
541
  let stalled = false;
542
+ let onsetWaitSeconds = 0;
543
+ let clockWaitSeconds = 0;
544
+ let lastNarrationTime;
545
+ const failNarration = (error, state) => {
546
+ setIsPlaying(false);
547
+ try {
548
+ void Promise.resolve(callbacksRef.current.onError?.(error, state)).catch(() => void 0);
549
+ } catch {
550
+ }
551
+ };
1023
552
  const reportStall = (next) => {
1024
553
  if (stalled === next) return;
1025
554
  stalled = next;
@@ -1033,14 +562,46 @@ function usePlaybackClock({
1033
562
  const tick = (now) => {
1034
563
  const current = stateRef.current;
1035
564
  const config = current.config;
1036
- const delta = Math.max(0, (now - previous) / 1e3);
565
+ const elapsed = Math.max(0, (now - previous) / 1e3);
566
+ let narrationReady = true;
567
+ try {
568
+ narrationReady = callbacksRef.current.narrationReady?.() !== false;
569
+ } catch (cause) {
570
+ failNarration(cause instanceof Error ? cause : new Error("Narration readiness failed"), current);
571
+ return;
572
+ }
573
+ onsetWaitSeconds = narrationReady ? 0 : onsetWaitSeconds + elapsed;
574
+ if (onsetWaitSeconds >= 8) {
575
+ failNarration(new Error("Narration did not report audio onset within eight seconds"), current);
576
+ return;
577
+ }
578
+ const delta = narrationReady ? elapsed : 0;
1037
579
  previous = now;
1038
580
  const settled = current.status === "complete" || current.status === "error" || current.status === "aborted";
1039
581
  const looping = loopRef.current && settled;
1040
582
  let wrapped = false;
1041
583
  if (config?.scenes.length) {
1042
584
  const duration2 = getVideoDuration(config);
1043
- const raw = timeRef.current + delta;
585
+ const ranges = resolveVideoTimeline(config);
586
+ const cued = ranges[sceneIndexRef.current];
587
+ let narrationTime;
588
+ try {
589
+ narrationTime = cued ? callbacksRef.current.narrationTime?.(cued.scene) : void 0;
590
+ } catch (cause) {
591
+ failNarration(cause instanceof Error ? cause : new Error("Narration clock failed"), current);
592
+ return;
593
+ }
594
+ if (narrationTime !== void 0 && (!Number.isFinite(narrationTime) || narrationTime < 0)) {
595
+ failNarration(new Error("Narration clock must return finite nonnegative seconds"), current);
596
+ return;
597
+ }
598
+ clockWaitSeconds = narrationTime !== void 0 && narrationTime === lastNarrationTime && narrationReady && !stalled ? clockWaitSeconds + elapsed : 0;
599
+ lastNarrationTime = narrationTime;
600
+ if (clockWaitSeconds >= 8) {
601
+ failNarration(new Error("Narration audio clock did not advance within eight seconds"), current);
602
+ return;
603
+ }
604
+ const raw = narrationTime !== void 0 && cued ? cued.start - (cued.scene.narrationGroup?.offsetSeconds ?? 0) + narrationTime : timeRef.current + delta;
1044
605
  let nextTime;
1045
606
  if (looping && duration2 > 0 && raw >= duration2) {
1046
607
  nextTime = raw % duration2;
@@ -1048,6 +609,9 @@ function usePlaybackClock({
1048
609
  } else {
1049
610
  nextTime = Math.min(raw, duration2);
1050
611
  }
612
+ const target = ranges.find((range) => nextTime >= range.start && nextTime < range.end) ?? ranges.at(-1);
613
+ const waitingForVisual = Boolean(target && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(target.scene) && !posterBridgeKeysRef?.current.has(sceneReadinessKey(target.scene)));
614
+ if (waitingForVisual && target) nextTime = target.start;
1051
615
  if (nextTime !== timeRef.current) {
1052
616
  timeRef.current = nextTime;
1053
617
  setCurrentTime(nextTime);
@@ -1065,19 +629,20 @@ function usePlaybackClock({
1065
629
  if (remaining <= 0 && !looping) audio.pause();
1066
630
  }
1067
631
  const notify = callbacksRef.current.onSceneChange;
1068
- if (notify) {
632
+ if (notify && !waitingForVisual) {
1069
633
  if (wrapped) sceneIndexRef.current = -1;
1070
- const ranges = resolveVideoTimeline(config);
1071
- const index = ranges.findIndex((range) => nextTime >= range.start && nextTime < range.end);
1072
- const resolved = index === -1 && nextTime >= duration2 ? ranges.length - 1 : index;
634
+ const ranges2 = resolveVideoTimeline(config);
635
+ const index = ranges2.findIndex((range) => nextTime >= range.start && nextTime < range.end);
636
+ const resolved = index === -1 && nextTime >= duration2 ? ranges2.length - 1 : index;
1073
637
  if (resolved !== -1 && resolved !== sceneIndexRef.current) {
1074
638
  sceneIndexRef.current = resolved;
1075
- notify(ranges[resolved].scene, resolved);
639
+ notify(ranges2[resolved].scene, resolved);
1076
640
  }
1077
641
  }
1078
642
  }
1079
643
  const duration = current.config ? getVideoDuration(current.config) : 0;
1080
- reportStall(!settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
644
+ const active = current.config ? resolveVideoTimeline(current.config).find((range) => timeRef.current >= range.start && timeRef.current < range.end) : void 0;
645
+ reportStall(Boolean(active && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(active.scene) && !posterBridgeKeysRef?.current.has(sceneReadinessKey(active.scene))) || !settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
1081
646
  if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
1082
647
  else setIsPlaying(false);
1083
648
  };
@@ -1120,7 +685,10 @@ function VideoPlayerRuntime({
1120
685
  onComplete,
1121
686
  onError,
1122
687
  onSceneChange,
688
+ narrationReady,
689
+ narrationTime,
1123
690
  onFramePresented,
691
+ onMediaFramePresented,
1124
692
  onStallChange,
1125
693
  onStateChange
1126
694
  }) {
@@ -1146,6 +714,7 @@ function VideoPlayerRuntime({
1146
714
  const [state, setState] = useState(() => video ? savedVideoState(video) : createVideoState());
1147
715
  const [currentTime, setCurrentTime] = useState(0);
1148
716
  const [activeStream, setActiveStream] = useState(stream);
717
+ const [activeSavedVideo, setActiveSavedVideo] = useState(video);
1149
718
  const [replacementPending, setReplacementPending] = useState(false);
1150
719
  const [startRequested, setStartRequested] = useState(autoStartGeneration);
1151
720
  const [introPlaying, setIntroPlaying] = useState(autoStartGeneration);
@@ -1156,13 +725,35 @@ function VideoPlayerRuntime({
1156
725
  const timeRef = useRef(currentTime);
1157
726
  const audioRef = useRef(null);
1158
727
  const introStartedAtRef = useRef(autoStartGeneration ? performance.now() : null);
1159
- const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, onFramePresented, onStallChange, onStateChange });
728
+ const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange });
1160
729
  const loopRef = useRef(loop);
1161
730
  const sceneIndexRef = useRef(-1);
731
+ const mediaFrameReportedRef = useRef(false);
732
+ const posterBridgeKeysRef = useRef(/* @__PURE__ */ new Set());
733
+ const visualReadyRef = useRef(void 0);
734
+ const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false, posterBridge = false) => {
735
+ if (posterBridge) {
736
+ posterBridgeKeysRef.current.add(key);
737
+ return;
738
+ }
739
+ if (error) {
740
+ setIsPlaying(false);
741
+ callbacksRef.current.onError?.(error, stateRef.current);
742
+ } else {
743
+ visualReadyRef.current = key;
744
+ if (actualVideoFrame && !mediaFrameReportedRef.current) {
745
+ mediaFrameReportedRef.current = true;
746
+ try {
747
+ void Promise.resolve(callbacksRef.current.onMediaFramePresented?.()).catch(() => void 0);
748
+ } catch {
749
+ }
750
+ }
751
+ }
752
+ }, []);
1162
753
  const playbackEndedRef = useRef(false);
1163
754
  stateRef.current = state;
1164
755
  timeRef.current = currentTime;
1165
- callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, onFramePresented, onStallChange, onStateChange };
756
+ callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange };
1166
757
  loopRef.current = loop;
1167
758
  const reportFramePresented = useMemo(() => {
1168
759
  let reported = false;
@@ -1191,9 +782,13 @@ function VideoPlayerRuntime({
1191
782
  output.default(audio, context);
1192
783
  }).catch(() => context.close());
1193
784
  };
1194
- if (stream !== activeStream) {
785
+ if (stream !== activeStream || video !== activeSavedVideo) {
1195
786
  const autoStartReplacement = Boolean(playbackMode && stream && shouldAutoPlay && !reducedMotion);
1196
787
  setActiveStream(stream);
788
+ setActiveSavedVideo(video);
789
+ mediaFrameReportedRef.current = false;
790
+ posterBridgeKeysRef.current.clear();
791
+ sceneIndexRef.current = -1;
1197
792
  setReplacementPending(stream != null);
1198
793
  setState(video ? savedVideoState(video) : createVideoState());
1199
794
  setCurrentTime(0);
@@ -1310,6 +905,8 @@ function VideoPlayerRuntime({
1310
905
  audioRef,
1311
906
  loopRef,
1312
907
  sceneIndexRef,
908
+ visualReadyRef,
909
+ posterBridgeKeysRef,
1313
910
  callbacksRef,
1314
911
  setCurrentTime,
1315
912
  setIsPlaying
@@ -1522,7 +1119,7 @@ function VideoPlayerRuntime({
1522
1119
  onStart: armPlayback
1523
1120
  }
1524
1121
  ),
1525
- !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx2(
1122
+ !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx2(MountedReadinessContext.Provider, { value: reportVisualReady, children: /* @__PURE__ */ jsx2(
1526
1123
  VideoFrame,
1527
1124
  {
1528
1125
  kit,
@@ -1542,7 +1139,7 @@ function VideoPlayerRuntime({
1542
1139
  transformOrigin: "top left"
1543
1140
  }
1544
1141
  }
1545
- ) : null,
1142
+ ) }) : null,
1546
1143
  /* @__PURE__ */ jsx2(
1547
1144
  StartPosterButton,
1548
1145
  {
@@ -1748,17 +1345,55 @@ function createSceneTimeline(options) {
1748
1345
  };
1749
1346
  }
1750
1347
 
1348
+ // src/player/scene-readiness.ts
1349
+ var READY_AHEAD_SECONDS = 8;
1350
+ function canStartPreparedSequence(scenes, complete) {
1351
+ let seconds = 0;
1352
+ let count = 0;
1353
+ for (const scene of scenes) {
1354
+ if (!scene) break;
1355
+ count += 1;
1356
+ seconds += scene.timing.fixedDuration ?? 0;
1357
+ if (seconds >= READY_AHEAD_SECONDS) return true;
1358
+ }
1359
+ return complete && count > 0 && count === scenes.length;
1360
+ }
1361
+ function preparedSceneDuration(scene, spokenSeconds, metadata) {
1362
+ const timing = metadata?.timing;
1363
+ const authored = (timing?.revealSeconds ?? 0) + (timing?.holdSeconds ?? 0) + (timing?.exitSeconds ?? 0);
1364
+ const readable = Math.max(getSceneDurationBounds(scene, metadata).readable, authored);
1365
+ return Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? Math.max(readable, spokenSeconds + 0.8) : Math.max(readable, getSceneDuration(scene, metadata));
1366
+ }
1367
+
1751
1368
  // src/player/use-narration.ts
1752
1369
  import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
1753
1370
  function useNarration(options) {
1754
1371
  const [speaking, setSpeaking] = useState2(false);
1755
1372
  const optionsRef = useRef2(options);
1756
1373
  optionsRef.current = options;
1374
+ const groupRef = useRef2(void 0);
1375
+ const clockRef = useRef2(void 0);
1376
+ const clockSceneRef = useRef2(void 0);
1377
+ const readyRef = useRef2(true);
1378
+ const getTime = useCallback((scene) => {
1379
+ const group = groupRef.current;
1380
+ if (optionsRef.current.enabled === false || (group ? scene.narrationGroup?.id !== group.id : scene.id !== clockSceneRef.current)) return void 0;
1381
+ if (!optionsRef.current.voice.getCurrentTime) return void 0;
1382
+ const time = currentRef.current ? optionsRef.current.voice.getCurrentTime() : void 0;
1383
+ if (currentRef.current && readyRef.current && time === void 0) return void 0;
1384
+ if (time !== void 0 && Number.isFinite(time)) clockRef.current = Math.min(group?.totalSeconds ?? Infinity, Math.max(group?.offsetSeconds ?? 0, time));
1385
+ return clockRef.current;
1386
+ }, []);
1387
+ const isReady = useCallback(() => optionsRef.current.enabled === false || readyRef.current || clockRef.current !== void 0 && clockRef.current >= (groupRef.current?.offsetSeconds ?? 0) + 0.04, []);
1757
1388
  const currentRef = useRef2(void 0);
1758
1389
  const spokenIndexRef = useRef2(void 0);
1759
1390
  const stop = useCallback(() => {
1391
+ readyRef.current = true;
1760
1392
  currentRef.current?.abort();
1761
1393
  currentRef.current = void 0;
1394
+ groupRef.current = void 0;
1395
+ clockRef.current = void 0;
1396
+ clockSceneRef.current = void 0;
1762
1397
  setSpeaking(false);
1763
1398
  }, []);
1764
1399
  const interrupt = useCallback(() => {
@@ -1770,37 +1405,103 @@ function useNarration(options) {
1770
1405
  const { voice, enabled = true } = optionsRef.current;
1771
1406
  if (!enabled) return;
1772
1407
  if (spokenIndexRef.current === index) return;
1408
+ const group = scene.narrationGroup;
1409
+ if (group && voice.supportsOffsets !== true) {
1410
+ stop();
1411
+ return;
1412
+ }
1413
+ if (group && currentRef.current && groupRef.current?.id === group.id && groupRef.current.text === group.text && spokenIndexRef.current === index - 1) {
1414
+ spokenIndexRef.current = index;
1415
+ return;
1416
+ }
1773
1417
  stop();
1774
1418
  spokenIndexRef.current = index;
1775
- const line = scene.narration?.trim();
1776
- if (!line) return;
1419
+ groupRef.current = group;
1420
+ clockRef.current = group?.offsetSeconds ?? 0;
1421
+ clockSceneRef.current = scene.id;
1422
+ const line = group?.text ?? scene.narration?.trim();
1423
+ if (!line) {
1424
+ clockRef.current = void 0;
1425
+ return;
1426
+ }
1777
1427
  const controller = new AbortController();
1778
1428
  currentRef.current = controller;
1429
+ readyRef.current = !group && !voice.getCurrentTime;
1779
1430
  setSpeaking(true);
1780
1431
  void (async () => {
1781
1432
  try {
1782
1433
  let started = false;
1783
1434
  await voice.speak(line, {
1784
1435
  signal: controller.signal,
1436
+ ...group ? { offsetSeconds: group.offsetSeconds } : {},
1785
1437
  onStart: (source) => {
1786
- if (started || controller.signal.aborted || currentRef.current !== controller || spokenIndexRef.current !== index || optionsRef.current.enabled === false) return;
1438
+ if (started || controller.signal.aborted || currentRef.current !== controller || !group && spokenIndexRef.current !== index || optionsRef.current.enabled === false) return;
1787
1439
  started = true;
1440
+ readyRef.current = true;
1788
1441
  try {
1789
1442
  void Promise.resolve(optionsRef.current.onSpeechStart?.(source)).catch(() => void 0);
1790
1443
  } catch {
1791
1444
  }
1792
1445
  }
1793
1446
  });
1447
+ if (currentRef.current === controller && !controller.signal.aborted) {
1448
+ clockRef.current = group && started ? group.totalSeconds : void 0;
1449
+ }
1794
1450
  } catch {
1451
+ if (currentRef.current === controller) {
1452
+ groupRef.current = void 0;
1453
+ clockRef.current = void 0;
1454
+ }
1795
1455
  } finally {
1796
1456
  if (currentRef.current === controller) {
1797
1457
  currentRef.current = void 0;
1458
+ readyRef.current = true;
1798
1459
  setSpeaking(false);
1799
1460
  }
1800
1461
  }
1801
1462
  })();
1802
1463
  }, [stop]);
1803
- return { onSceneChange, interrupt, speaking };
1464
+ return { onSceneChange, interrupt, speaking, isReady, getTime };
1465
+ }
1466
+
1467
+ // src/player/prepare-scene-media.ts
1468
+ async function prepareSceneMedia(variables, signal) {
1469
+ signal.throwIfAborted();
1470
+ const url = typeof variables.mediaUrl === "string" ? variables.mediaUrl.trim() : "";
1471
+ if (!url || variables.mediaType === "gradient") return "graphic";
1472
+ const video = variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(url);
1473
+ const poster = typeof variables.mediaPoster === "string" ? variables.mediaPoster.trim() : "";
1474
+ if (video && !poster) return "awaiting-video-frame";
1475
+ let posterFailed = false;
1476
+ await new Promise((resolve, reject) => {
1477
+ const image = new Image();
1478
+ let settled = false;
1479
+ const finish = (error) => {
1480
+ if (settled) return;
1481
+ settled = true;
1482
+ clearTimeout(timer);
1483
+ signal.removeEventListener("abort", abort);
1484
+ image.onload = null;
1485
+ image.onerror = null;
1486
+ if (error) {
1487
+ image.src = "";
1488
+ reject(error);
1489
+ } else resolve();
1490
+ };
1491
+ const abort = () => finish(signal.reason ?? new DOMException("Cancelled", "AbortError"));
1492
+ const timer = setTimeout(() => finish(new DOMException("Media preparation timed out", "TimeoutError")), 8e3);
1493
+ signal.addEventListener("abort", abort, { once: true });
1494
+ image.onload = () => {
1495
+ void (image.decode ? image.decode() : Promise.resolve()).then(() => finish(), () => finish(new Error("Could not prepare scene image")));
1496
+ };
1497
+ image.onerror = () => finish(new Error("Could not prepare scene image"));
1498
+ image.src = video ? poster : url;
1499
+ if (image.complete && image.naturalWidth > 0) image.onload(new Event("load"));
1500
+ }).catch((error) => {
1501
+ if (!video || signal.aborted) throw error;
1502
+ posterFailed = true;
1503
+ });
1504
+ return video ? posterFailed ? "awaiting-video-frame" : "poster-bridge" : "image";
1804
1505
  }
1805
1506
 
1806
1507
  // src/video-chat/voice.ts
@@ -1820,10 +1521,10 @@ async function measureSeconds(bytes) {
1820
1521
  try {
1821
1522
  sharedContext ??= new AudioContext();
1822
1523
  const decoded = await sharedContext.decodeAudioData(bytes.slice(0));
1823
- if (decoded.duration > 0) return decoded.duration;
1524
+ if (decoded.duration > 0) return { seconds: decoded.duration, measured: true };
1824
1525
  } catch {
1825
1526
  }
1826
- return bytes.byteLength * 8 / FALLBACK_BITS_PER_SECOND;
1527
+ return { seconds: bytes.byteLength * 8 / FALLBACK_BITS_PER_SECOND, measured: false };
1827
1528
  }
1828
1529
  function createVideoChatVoice(options = {}) {
1829
1530
  const endpoint = options.endpoint ?? "/api/video-chat";
@@ -1898,7 +1599,7 @@ function createVideoChatVoice(options = {}) {
1898
1599
  createdSrc = URL.createObjectURL(new Blob([bytes], {
1899
1600
  type: response.headers.get("content-type") || "audio/mpeg"
1900
1601
  }));
1901
- return { source: "generated", src: createdSrc, seconds };
1602
+ return { source: "generated", src: createdSrc, ...seconds };
1902
1603
  }, SPEECH_PREPARATION_TIMEOUT_MS, controller.signal);
1903
1604
  }
1904
1605
  } catch (cause) {
@@ -1944,9 +1645,11 @@ function createVideoChatVoice(options = {}) {
1944
1645
  browserFinish?.();
1945
1646
  };
1946
1647
  return {
1648
+ supportsOffsets: true,
1649
+ getCurrentTime: () => sounding?.currentTime,
1947
1650
  async prepare(text, preparation = {}) {
1948
1651
  const line = await load(text, preparation.signal);
1949
- return { seconds: line.seconds };
1652
+ return { seconds: line.seconds, ...line.source === "generated" && line.measured === true ? { supportsOffsets: true } : {} };
1950
1653
  },
1951
1654
  pause() {
1952
1655
  held = true;
@@ -1955,7 +1658,7 @@ function createVideoChatVoice(options = {}) {
1955
1658
  },
1956
1659
  resume() {
1957
1660
  held = false;
1958
- if (sounding && !silent) void sounding.play().catch(() => playbackFailure?.());
1661
+ if (sounding) void sounding.play().catch(() => playbackFailure?.());
1959
1662
  if (!silent) globalThis.speechSynthesis?.resume();
1960
1663
  },
1961
1664
  setMuted(muted) {
@@ -1963,7 +1666,7 @@ function createVideoChatVoice(options = {}) {
1963
1666
  if (sounding) sounding.muted = muted;
1964
1667
  if (muted) stopBrowser();
1965
1668
  },
1966
- async speak(text, { signal, onStart }) {
1669
+ async speak(text, { signal, onStart, offsetSeconds }) {
1967
1670
  let started = false;
1968
1671
  const notifyStart = (source) => {
1969
1672
  if (started || disposed || signal.aborted || silent || held) return;
@@ -1975,17 +1678,21 @@ function createVideoChatVoice(options = {}) {
1975
1678
  };
1976
1679
  const line = await load(text, signal);
1977
1680
  if (disposed || signal.aborted || silent) return;
1681
+ if (offsetSeconds !== void 0 && (line.source !== "generated" || !line.measured || !Number.isFinite(offsetSeconds) || offsetSeconds < 0 || offsetSeconds >= line.seconds)) throw new Error("Narration group requires measured, seekable audio");
1978
1682
  if (line.source === "browser") {
1979
1683
  const synthesis = globalThis.speechSynthesis;
1980
- if (!synthesis || typeof SpeechSynthesisUtterance === "undefined") return;
1684
+ if (!synthesis || typeof SpeechSynthesisUtterance === "undefined") throw new Error("Browser voice is unavailable");
1981
1685
  const utterance = new SpeechSynthesisUtterance(text);
1982
1686
  utterance.rate = 1;
1687
+ let unavailable = false;
1983
1688
  await new Promise((resolve) => {
1984
1689
  let finished = false;
1690
+ let onsetRemainingMs = 2e3;
1985
1691
  const finish = () => {
1986
1692
  if (finished) return;
1987
1693
  finished = true;
1988
1694
  clearWatchdog();
1695
+ clearInterval(onsetTimer);
1989
1696
  signal.removeEventListener("abort", stop);
1990
1697
  speechStops.delete(stop);
1991
1698
  utterance.onstart = null;
@@ -1995,38 +1702,54 @@ function createVideoChatVoice(options = {}) {
1995
1702
  resolve();
1996
1703
  };
1997
1704
  const stop = () => {
1998
- synthesis.cancel();
1999
1705
  finish();
1706
+ synthesis.cancel();
2000
1707
  };
2001
1708
  const clearWatchdog = watchSpeech(estimatedBrowserSeconds(text), stop);
2002
1709
  speechStops.add(stop);
2003
1710
  browserFinish = finish;
1711
+ const fail = () => {
1712
+ unavailable = true;
1713
+ stop();
1714
+ };
1715
+ const onsetTimer = setInterval(() => {
1716
+ if (held || finished) return;
1717
+ onsetRemainingMs -= 250;
1718
+ if (onsetRemainingMs <= 0) fail();
1719
+ }, 250);
2004
1720
  utterance.onstart = () => {
2005
- if (!finished) notifyStart("browser");
1721
+ if (finished) return;
1722
+ clearInterval(onsetTimer);
1723
+ notifyStart("browser");
2006
1724
  };
2007
1725
  utterance.onend = finish;
2008
- utterance.onerror = finish;
1726
+ utterance.onerror = fail;
2009
1727
  signal.addEventListener("abort", stop, { once: true });
2010
1728
  try {
2011
1729
  synthesis.speak(utterance);
2012
1730
  if (held) synthesis.pause();
2013
1731
  } catch {
2014
- finish();
1732
+ fail();
2015
1733
  }
2016
1734
  });
1735
+ if (unavailable) throw new Error("Browser voice is unavailable");
2017
1736
  return;
2018
1737
  }
2019
1738
  let playbackFailed = false;
2020
1739
  try {
2021
1740
  const element = new Audio(line.src);
2022
1741
  element.muted = silent;
1742
+ if (offsetSeconds !== void 0) element.currentTime = offsetSeconds;
2023
1743
  sounding = element;
2024
1744
  await new Promise((resolve) => {
2025
1745
  let finished = false;
1746
+ let onsetTimer;
1747
+ const initialTime = offsetSeconds ?? 0;
2026
1748
  const finish = () => {
2027
1749
  if (finished) return;
2028
1750
  finished = true;
2029
1751
  clearWatchdog();
1752
+ clearTimeout(onsetTimer);
2030
1753
  signal.removeEventListener("abort", stop);
2031
1754
  speechStops.delete(stop);
2032
1755
  element.onplaying = null;
@@ -2049,9 +1772,13 @@ function createVideoChatVoice(options = {}) {
2049
1772
  const clearWatchdog = watchSpeech(Math.max(line.seconds, estimatedBrowserSeconds(text)), fail);
2050
1773
  speechStops.add(stop);
2051
1774
  playbackFailure = fail;
2052
- element.onplaying = () => {
2053
- if (!finished) notifyStart("generated");
1775
+ const observeClock = () => {
1776
+ clearTimeout(onsetTimer);
1777
+ if (finished || started) return;
1778
+ if (!held && element.currentTime >= initialTime + 0.04) notifyStart("generated");
1779
+ if (!started) onsetTimer = setTimeout(observeClock, 16);
2054
1780
  };
1781
+ element.onplaying = observeClock;
2055
1782
  element.onended = finish;
2056
1783
  element.onerror = fail;
2057
1784
  signal.addEventListener("abort", stop, { once: true });
@@ -2064,6 +1791,7 @@ function createVideoChatVoice(options = {}) {
2064
1791
  } catch {
2065
1792
  playbackFailed = true;
2066
1793
  }
1794
+ if (playbackFailed && offsetSeconds !== void 0) throw new Error("Grouped narration playback failed");
2067
1795
  if (playbackFailed && !disposed && !signal.aborted && !silent) {
2068
1796
  notifyFallback();
2069
1797
  URL.revokeObjectURL(line.src);
@@ -2324,7 +2052,11 @@ async function responseError(response) {
2324
2052
  });
2325
2053
  }
2326
2054
  function pacedScene(scene, spokenSeconds, templates) {
2327
- const held = spokenSeconds && spokenSeconds > 0 ? spokenSeconds + 0.8 : getSceneDuration(scene, templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId));
2055
+ const held = preparedSceneDuration(
2056
+ scene,
2057
+ spokenSeconds,
2058
+ templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId)
2059
+ );
2328
2060
  const {
2329
2061
  startTime: _startTime,
2330
2062
  endTime: _endTime,
@@ -2334,21 +2066,6 @@ function pacedScene(scene, spokenSeconds, templates) {
2334
2066
  } = scene.timing ?? {};
2335
2067
  return { ...scene, timing: { ...timing, fixedDuration: held } };
2336
2068
  }
2337
- function prepareVisualScene(scene, mode) {
2338
- const overMedia = typeof scene.variables.mediaUrl === "string";
2339
- const filmed = mode === "full" && overMedia;
2340
- const shown = filmed ? {
2341
- ...scene,
2342
- backgroundEffect: "static",
2343
- variables: {
2344
- ...scene.variables,
2345
- texts: "",
2346
- mediaTreatment: "none",
2347
- confetti: false
2348
- }
2349
- } : scene;
2350
- return overMedia ? { ...shown, textArchetype: "subtle" } : shown;
2351
- }
2352
2069
  function useVideoChat(options = {}) {
2353
2070
  return useVideoChatSession(options).chat;
2354
2071
  }
@@ -2372,7 +2089,17 @@ function useVideoChatSession(options = {}) {
2372
2089
  const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
2373
2090
  const speechStartRef = useRef3(() => void 0);
2374
2091
  const narration = useNarration({ onSpeechStart: (source) => speechStartRef.current(source), voice: {
2375
- speak: (text, options2) => unavailableVoiceLines.current.has(text) ? void 0 : voice.speak(text, options2)
2092
+ supportsOffsets: voice.supportsOffsets,
2093
+ ...voice.getCurrentTime ? { getCurrentTime: () => voice.getCurrentTime() } : {},
2094
+ speak: async (text, options2) => {
2095
+ if (unavailableVoiceLines.current.has(text)) return;
2096
+ try {
2097
+ await voice.speak(text, options2);
2098
+ } catch (error) {
2099
+ if (!options2.signal.aborted) voiceWarningRef.current("Voice playback is unavailable. Continuing with subtitles.");
2100
+ throw error;
2101
+ }
2102
+ }
2376
2103
  } });
2377
2104
  const narrationRef = useRef3(narration);
2378
2105
  narrationRef.current = narration;
@@ -2511,9 +2238,7 @@ function useVideoChatSession(options = {}) {
2511
2238
  throw new VideoError("timeoutMs must be positive", { code: "invalid_option" });
2512
2239
  }
2513
2240
  const timeout = setTimeout(() => controller.abort(new DOMException("Video chat timed out", "TimeoutError")), timeoutMs);
2514
- const requestedMode = currentOptions.mode ?? "templates";
2515
- const knownModes = stateRef.current.capabilities?.modes;
2516
- const mode = knownModes && !knownModes.includes(requestedMode) ? "templates" : requestedMode;
2241
+ const mode = "cinematic";
2517
2242
  const orientation = currentOptions.orientation ?? "landscape";
2518
2243
  const id = (currentOptions.createTurnId ?? defaultTurnId)();
2519
2244
  const conversation = conversationFor(stateRef.current.turns);
@@ -2524,7 +2249,7 @@ function useVideoChatSession(options = {}) {
2524
2249
  prompt,
2525
2250
  completed: false,
2526
2251
  orientation,
2527
- fixedOrientation: mode === "full",
2252
+ fixedOrientation: true,
2528
2253
  suggestions: [],
2529
2254
  ...openingMedia ? { openingMedia } : {}
2530
2255
  };
@@ -2547,11 +2272,23 @@ function useVideoChatSession(options = {}) {
2547
2272
  const warn = (message) => {
2548
2273
  if (isCurrent()) dispatch({ type: "warning", id, message });
2549
2274
  };
2550
- voiceWarningRef.current = () => warn("Using browser voice for this response.");
2275
+ voiceWarningRef.current = (message = "Using browser voice for this response.") => warn(message);
2551
2276
  const flush = () => {
2552
2277
  if (!isCurrent()) return;
2553
2278
  let available = appended;
2554
- while (ready[available]) available += 1;
2279
+ while (ready[available]) {
2280
+ const group = ready[available].narrationGroup;
2281
+ if (!group) {
2282
+ available++;
2283
+ continue;
2284
+ }
2285
+ let end = available;
2286
+ while (ready[end]?.narrationGroup?.id === group.id) end++;
2287
+ const last = ready[end - 1].narrationGroup;
2288
+ if (Math.abs(last.offsetSeconds + last.durationSeconds - group.totalSeconds) > 0.02) break;
2289
+ validateNarrationGroups(ready.slice(available, end));
2290
+ available = end;
2291
+ }
2555
2292
  if (available === appended && timeline) {
2556
2293
  if (planDone && !timelineCompleted) {
2557
2294
  timelineCompleted = true;
@@ -2561,18 +2298,18 @@ function useVideoChatSession(options = {}) {
2561
2298
  return;
2562
2299
  }
2563
2300
  if (!timeline) {
2564
- if (!style || openingActive || heldRef.current || available === appended) return;
2301
+ if (!style || openingActive || heldRef.current || available === appended || !canStartPreparedSequence(ready.slice(0, available), planDone)) return;
2565
2302
  timeline = createSceneTimeline({ style, orientation });
2566
2303
  timelineRef.current = timeline;
2567
2304
  openingController.abort(new DOMException("Opening replaced by response", "AbortError"));
2568
2305
  if (openingRef.current === openingController) openingRef.current = void 0;
2569
2306
  dispatch({ type: "player", id, stream: timeline.stream });
2570
2307
  }
2571
- while (ready[appended]) timeline.add(ready[appended++]);
2308
+ while (appended < available) timeline.add(ready[appended++]);
2572
2309
  dispatch({
2573
2310
  type: "partial",
2574
2311
  id,
2575
- video: { schemaVersion: "0.1", orientation, scenes: ready.slice(0, appended), style }
2312
+ video: { schemaVersion: VIDEO_SCHEMA_VERSION, orientation, scenes: ready.slice(0, appended), style }
2576
2313
  });
2577
2314
  if (planDone && !timelineCompleted) {
2578
2315
  timelineCompleted = true;
@@ -2634,7 +2371,6 @@ function useVideoChatSession(options = {}) {
2634
2371
  mode,
2635
2372
  orientation,
2636
2373
  conversation,
2637
- ...currentOptions.brand ? { brand: currentOptions.brand } : {},
2638
2374
  ...currentOptions.style ? { style: currentOptions.style } : {}
2639
2375
  })
2640
2376
  }, controller.signal);
@@ -2649,7 +2385,7 @@ function useVideoChatSession(options = {}) {
2649
2385
  let terminalError;
2650
2386
  try {
2651
2387
  for await (const event of decodeVideoSse(response.body)) {
2652
- if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: "0.1", orientation, scenes: [], style }, lines: [] };
2388
+ if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: VIDEO_SCHEMA_VERSION, orientation, scenes: [], style }, lines: [] };
2653
2389
  if (event.type === "response.start") style = event.data.style;
2654
2390
  if (event.type === "response.warning" || event.type === "response.error" && !event.data.terminal) {
2655
2391
  warn(event.type === "response.warning" && event.data.warning.message === MEDIA_RECOVERY_NOTICE ? MEDIA_RECOVERY_NOTICE : "Some parts were simplified so the response could continue.");
@@ -2683,10 +2419,12 @@ function useVideoChatSession(options = {}) {
2683
2419
  const position = event.data.position;
2684
2420
  const plannedScene = event.data.scene;
2685
2421
  planned[position] = plannedScene;
2686
- received[position] = prepareVisualScene(plannedScene, mode);
2687
- if (!currentOptions.templates?.getTemplate(plannedScene.templateId)) {
2688
- preloadBuiltinTemplate(plannedScene.templateId);
2689
- }
2422
+ received[position] = plannedScene;
2423
+ const rendererReady = currentOptions.templates?.getTemplate(plannedScene.templateId) ? Promise.resolve() : Promise.resolve(preloadBuiltinTemplate(plannedScene.templateId));
2424
+ const visualPreparation = Promise.all([rendererReady, prepareSceneMedia(plannedScene.variables, controller.signal)]).then(() => void 0, () => {
2425
+ throw new VideoError("Scene could not prepare its visual", { code: "media_not_ready" });
2426
+ });
2427
+ void visualPreparation.catch(() => void 0);
2690
2428
  warmSceneMedia(plannedScene.variables);
2691
2429
  const narrated = narrating.then(async () => {
2692
2430
  const supplied = plannedScene.narration?.trim();
@@ -2713,18 +2451,25 @@ function useVideoChatSession(options = {}) {
2713
2451
  narrating = narrated.catch(() => "");
2714
2452
  pending.push(narrated.then(async (line) => {
2715
2453
  if (!isCurrent() || currentAttempt !== attempt) return;
2716
- const visual = prepareVisualScene(plannedScene, mode);
2454
+ const visual = plannedScene;
2717
2455
  const withNarration = line ? { ...visual, narration: line } : visual;
2718
- const spoken = line ? await prepareSpeech(line, controller.signal).catch((cause) => {
2456
+ const spoken = line ? await prepareSpeech(plannedScene.narrationGroup?.text ?? line, controller.signal).catch((cause) => {
2719
2457
  if (controller.signal.aborted) throw cause;
2720
2458
  warn("Some narration is unavailable; the response will continue.");
2721
2459
  return void 0;
2722
2460
  }) : void 0;
2723
2461
  if (!isCurrent() || currentAttempt !== attempt) return;
2724
- ready[position] = pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2462
+ await visualPreparation;
2463
+ const group = plannedScene.narrationGroup;
2464
+ 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" });
2465
+ ready[position] = group ? withNarration : pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2725
2466
  flush();
2726
- }).catch(() => {
2467
+ }).catch((cause) => {
2727
2468
  if (!isCurrent() || currentAttempt !== attempt) return;
2469
+ if (cause instanceof VideoError && ["media_not_ready", "narration_group_invalid"].includes(cause.code)) {
2470
+ terminalError = cause;
2471
+ return;
2472
+ }
2728
2473
  ready[position] = { ...received[position], timing: { fixedDuration: 5 } };
2729
2474
  warn("Some parts were simplified so the response could continue.");
2730
2475
  flush();
@@ -2735,6 +2480,8 @@ function useVideoChatSession(options = {}) {
2735
2480
  terminalError = errorFrom(cause);
2736
2481
  }
2737
2482
  await Promise.all(pending);
2483
+ if (terminalError && ["media_not_ready", "narration_group_invalid"].includes(terminalError.code)) throw terminalError;
2484
+ validateNarrationGroups(ready.filter((scene) => Boolean(scene)));
2738
2485
  if (terminalError && ready.some(Boolean) && style) {
2739
2486
  warn("The response was interrupted; completed scenes are still available.");
2740
2487
  } else if (terminalError) throw terminalError;
@@ -2743,7 +2490,7 @@ function useVideoChatSession(options = {}) {
2743
2490
  }
2744
2491
  return {
2745
2492
  video: {
2746
- schemaVersion: "0.1",
2493
+ schemaVersion: VIDEO_SCHEMA_VERSION,
2747
2494
  orientation,
2748
2495
  scenes: ready.filter((entry) => entry != null),
2749
2496
  style
@@ -2756,7 +2503,7 @@ function useVideoChatSession(options = {}) {
2756
2503
  try {
2757
2504
  response = await untilAborted(runAttempt(attempt), controller.signal);
2758
2505
  } catch (cause) {
2759
- if (controller.signal.aborted || timeline || spokenHook) throw cause;
2506
+ 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;
2760
2507
  attempt += 1;
2761
2508
  ready = [];
2762
2509
  received = [];
@@ -2792,9 +2539,9 @@ function useVideoChatSession(options = {}) {
2792
2539
  return void 0;
2793
2540
  }
2794
2541
  terminal = true;
2795
- const recovered = received.flatMap((scene, index) => scene ? [ready[index] ?? { ...scene, timing: { fixedDuration: 5 } }] : []);
2542
+ const recovered = received.some((scene) => scene?.narrationGroup) || cause instanceof VideoError && ["media_not_ready", "narration_group_invalid"].includes(cause.code) ? [] : received.flatMap((scene, index) => scene ? [ready[index] ?? { ...scene, timing: { fixedDuration: 5 } }] : []);
2796
2543
  if (recovered.length > 0) {
2797
- style ??= { brand: resolveVideoBrand(currentOptions.brand), density: "normal", motion: "normal", defaultBackgroundEffect: "static", defaultTextArchetype: "subtle", defaultTransition: "crossfade" };
2544
+ style ??= { density: "normal", motion: "normal", defaultBackgroundEffect: "static", defaultTextArchetype: "subtle", defaultTransition: "crossfade" };
2798
2545
  openingController.abort(new DOMException("Continuing completed response", "AbortError"));
2799
2546
  if (!timeline) {
2800
2547
  timeline = createSceneTimeline({ style, orientation });
@@ -2803,7 +2550,7 @@ function useVideoChatSession(options = {}) {
2803
2550
  for (const scene of recovered.slice(appended)) timeline.add(scene);
2804
2551
  timeline.complete();
2805
2552
  if (timelineRef.current === timeline) timelineRef.current = void 0;
2806
- const video = { schemaVersion: "0.1", orientation, style, scenes: recovered };
2553
+ const video = { schemaVersion: VIDEO_SCHEMA_VERSION, orientation, style, scenes: recovered };
2807
2554
  dispatch({ type: "warning", id, message: "The response was interrupted; completed scenes are still available." });
2808
2555
  dispatch({ type: "complete", id, video, suggestions: [] });
2809
2556
  return video;
@@ -2831,9 +2578,18 @@ function useVideoChatSession(options = {}) {
2831
2578
  dispatch({ type: "resume" });
2832
2579
  flushRef.current?.();
2833
2580
  }, []);
2581
+ const prepareSavedGroups = useCallback2(async (video) => {
2582
+ validateNarrationGroups(video.scenes);
2583
+ const groups = new Map(video.scenes.flatMap((scene) => scene.narrationGroup ? [[scene.narrationGroup.id, scene.narrationGroup]] : []));
2584
+ for (const group of groups.values()) {
2585
+ const prepared = await withDeadline((signal) => voiceRef.current.prepare(group.text, { signal }), 3e3);
2586
+ 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" });
2587
+ }
2588
+ }, []);
2834
2589
  const replay = useCallback2(() => {
2835
2590
  const turn = stateRef.current.turns.find((entry) => entry.id === stateRef.current.shownTurnId);
2836
2591
  if (!turn?.completed || !turn.video) return;
2592
+ runRef.current += 1;
2837
2593
  endTiming();
2838
2594
  if (inFlightRef.current) {
2839
2595
  runRef.current += 1;
@@ -2847,11 +2603,20 @@ function useVideoChatSession(options = {}) {
2847
2603
  narrationRef.current.interrupt();
2848
2604
  heldRef.current = false;
2849
2605
  voiceRef.current.resume();
2850
- dispatch({ type: "replay" });
2851
- }, [endTiming]);
2606
+ if (turn.video.scenes.some((scene) => scene.narrationGroup)) {
2607
+ const selection = ++runRef.current;
2608
+ dispatch({ type: "pause" });
2609
+ void prepareSavedGroups(turn.video).then(() => {
2610
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "replay" });
2611
+ }).catch((cause) => {
2612
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "error", id: turn.id, error: errorFrom(cause) });
2613
+ });
2614
+ } else dispatch({ type: "replay" });
2615
+ }, [endTiming, prepareSavedGroups]);
2852
2616
  const selectTurn = useCallback2((id) => {
2853
2617
  const turn = stateRef.current.turns.find((entry) => entry.id === id);
2854
2618
  if (!turn?.completed || !turn.video) return;
2619
+ runRef.current += 1;
2855
2620
  endTiming();
2856
2621
  if (inFlightRef.current) {
2857
2622
  runRef.current += 1;
@@ -2865,8 +2630,16 @@ function useVideoChatSession(options = {}) {
2865
2630
  narrationRef.current.interrupt();
2866
2631
  heldRef.current = false;
2867
2632
  voiceRef.current.resume();
2868
- dispatch({ type: "select", id });
2869
- }, [endTiming]);
2633
+ if (turn.video.scenes.some((scene) => scene.narrationGroup)) {
2634
+ const selection = ++runRef.current;
2635
+ dispatch({ type: "pause" });
2636
+ void prepareSavedGroups(turn.video).then(() => {
2637
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "select", id });
2638
+ }).catch((cause) => {
2639
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "error", id, error: errorFrom(cause) });
2640
+ });
2641
+ } else dispatch({ type: "select", id });
2642
+ }, [endTiming, prepareSavedGroups]);
2870
2643
  const reset = useCallback2(() => {
2871
2644
  cancel("Session reset");
2872
2645
  heldRef.current = false;
@@ -2882,7 +2655,7 @@ function useVideoChatSession(options = {}) {
2882
2655
  const setMuted = useCallback2((muted) => dispatch({ type: "mute", value: muted }), []);
2883
2656
  const currentTurn = state.turns.at(-1);
2884
2657
  const shownTurn = state.turns.find((turn) => turn.id === state.shownTurnId) ?? currentTurn;
2885
- const availableModes = state.capabilities?.modes ?? ["templates"];
2658
+ const availableModes = state.capabilities?.modes ?? ["cinematic"];
2886
2659
  const suggestions = shownTurn?.suggestions ?? [];
2887
2660
  const fullTranscript = shownTurn ? transcriptFor(shownTurn) : [];
2888
2661
  const transcript = shownTurn && shownTurn === currentTurn && state.playback?.kind !== "video" ? fullTranscript.slice(0, (shownTurn.opening ? 1 : 0) + state.spokenUpTo + 1) : fullTranscript;
@@ -2893,6 +2666,8 @@ function useVideoChatSession(options = {}) {
2893
2666
  autoPlay: true,
2894
2667
  paused: state.status === "paused",
2895
2668
  controls: false,
2669
+ narrationReady: narration.isReady,
2670
+ narrationTime: narration.getTime,
2896
2671
  orientation: shownTurn?.fixedOrientation ? shownTurn.orientation : "auto",
2897
2672
  onFramePresented: () => {
2898
2673
  const timing = firstFrameRef.current;
@@ -2902,8 +2677,22 @@ function useVideoChatSession(options = {}) {
2902
2677
  observe(() => optionsRef.current.onFirstFrame?.({ turnId: timing.turnId, mode: timing.mode, timeToFirstFrameMs: elapsedMs }));
2903
2678
  reportMetric({ type: "first-frame", turnId: timing.turnId, mode: timing.mode, elapsedMs });
2904
2679
  },
2680
+ onMediaFramePresented: () => {
2681
+ const timing = firstFrameRef.current;
2682
+ if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream" || !timing?.active || timing.mediaReported) return;
2683
+ timing.mediaReported = true;
2684
+ reportMetric({
2685
+ type: "first-media-frame",
2686
+ turnId: timing.turnId,
2687
+ mode: timing.mode,
2688
+ elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt))
2689
+ });
2690
+ },
2905
2691
  onStallChange: (stalled) => {
2906
- if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream") return;
2692
+ if (stateRef.current.playerKey !== playbackKey) return;
2693
+ if (stalled) voiceRef.current.pause();
2694
+ else if (!heldRef.current) voiceRef.current.resume();
2695
+ if (state.playback?.kind !== "stream") return;
2907
2696
  const timing = firstFrameRef.current;
2908
2697
  if (!timing?.active || !timing.reported) return;
2909
2698
  if (!stalled) finishStall();
@@ -2921,6 +2710,7 @@ function useVideoChatSession(options = {}) {
2921
2710
  },
2922
2711
  onError: (cause) => {
2923
2712
  if (stateRef.current.playerKey !== playbackKey) return;
2713
+ narrationRef.current.interrupt();
2924
2714
  const id = stateRef.current.turns.at(-1)?.id;
2925
2715
  if (id) dispatch({ type: "error", id, error: errorFrom(cause) });
2926
2716
  }
@@ -2956,68 +2746,7 @@ function useVideoChatSession(options = {}) {
2956
2746
  }
2957
2747
 
2958
2748
  // src/video-chat/video-chat.tsx
2959
- import { useCallback as useCallback6, useEffect as useEffect9, useId, useLayoutEffect, useMemo as useMemo2, useRef as useRef7, useState as useState6 } from "react";
2960
-
2961
- // src/video-chat/modes.ts
2962
- var visualModes = [
2963
- { id: "templates", label: "Templates only", note: "Animated text, diagrams and charts" },
2964
- { id: "full", label: "Full AI video", note: "AI-generated footage \xB7 billed per clip" }
2965
- ];
2966
- var defaultMode = visualModes[0];
2967
- var modeById = (id) => visualModes.find((mode) => mode.id === id) ?? defaultMode;
2968
-
2969
- // src/video-chat/themes.ts
2970
- var GROUND = {
2971
- font: "Inter",
2972
- scriptFont: "Caveat",
2973
- // The brand's indigo into blue. Deeper than it looks like it wants to be:
2974
- // the renderer holds captions to 4.5:1 against this ground, and the brand's
2975
- // own bright blue (#00A5FF) measures 2.68:1 under white - so the light end
2976
- // stays at a blue that clears the bar (5.17:1) and the bright one is spent
2977
- // on accents instead, where it sits on the dark ground rather than under
2978
- // white type.
2979
- background: { colors: ["#1C0277", "#2563eb"] },
2980
- foreground: "#ffffff",
2981
- surface: "#1C0277",
2982
- surfaceElevated: "#2f4fc4",
2983
- // Not the brand's #7065A5: muted text still has to be read, and that violet
2984
- // measures 3.66:1 on the dark end of this ground and worse on the light one.
2985
- // The brand violet earns its place as a surface, not as type.
2986
- muted: "#d7d3f0"
2987
- };
2988
- function look(primary, secondary) {
2989
- const { font, scriptFont, background, ...colors } = GROUND;
2990
- return { font, scriptFont, background, colors: { ...colors, primary, secondary } };
2991
- }
2992
- var themes = [
2993
- {
2994
- id: "documentary",
2995
- label: "Documentary",
2996
- brand: look("#FF3A71", "#FFB100"),
2997
- generatedLook: "Natural daylight, documentary realism, shallow depth of field, fine 35mm grain, muted natural palette. Photoreal, no illustration."
2998
- },
2999
- {
3000
- id: "illustrated",
3001
- label: "Illustrated",
3002
- brand: look("#00A5FF", "#FF006F"),
3003
- generatedLook: "Hand-drawn cel animation, rough ink contours, visible paper grain, flat cobalt, crimson and ochre against a deep ground. No photorealism."
3004
- },
3005
- {
3006
- id: "blueprint",
3007
- label: "Blueprint",
3008
- brand: look("#00A5FF", "#FFB100"),
3009
- generatedLook: "Technical blueprint drawing, precise white linework on deep cyanotype blue, faint measurement grid, one warm accent. Schematic, no photorealism."
3010
- }
3011
- ];
3012
- function themeBackground(brand) {
3013
- const background = brand.background;
3014
- if (background?.colors && background.colors.length >= 2) {
3015
- return `linear-gradient(175deg, ${background.colors[0]} 0%, ${background.colors[1]} 100%)`;
3016
- }
3017
- return background?.color ?? "#4c2ba8";
3018
- }
3019
- var defaultTheme = themes[0];
3020
- var themeById = (id) => themes.find((theme) => theme.id === id) ?? defaultTheme;
2749
+ import { useCallback as useCallback6, useEffect as useEffect9, useId, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef8, useState as useState7 } from "react";
3021
2750
 
3022
2751
  // src/video-chat/icons.tsx
3023
2752
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
@@ -3129,7 +2858,7 @@ function useFocusTrap(active, surface) {
3129
2858
  // src/video-chat/suggestion-cards.tsx
3130
2859
  import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
3131
2860
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3132
- function Frame({ media, poster, playing }) {
2861
+ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = false }) {
3133
2862
  const video = useRef4(null);
3134
2863
  const [playingUrl, setPlayingUrl] = useState3(null);
3135
2864
  useEffect6(() => {
@@ -3139,7 +2868,12 @@ function Frame({ media, poster, playing }) {
3139
2868
  else element.pause();
3140
2869
  }, [playing]);
3141
2870
  if (!media) return null;
3142
- if (media.type === "image") return /* @__PURE__ */ jsx4("img", { className: "frame-media", src: media.url, alt: "" });
2871
+ const ready = () => {
2872
+ setPlayingUrl(media.url);
2873
+ onReady?.();
2874
+ };
2875
+ const appearance = revealWhenReady ? { opacity: playingUrl === media.url ? 1 : 0, transition: "opacity 200ms ease" } : void 0;
2876
+ if (media.type === "image") return /* @__PURE__ */ jsx4("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
3143
2877
  return /* @__PURE__ */ jsxs4(Fragment2, { children: [
3144
2878
  /* @__PURE__ */ jsx4(
3145
2879
  "video",
@@ -3148,8 +2882,12 @@ function Frame({ media, poster, playing }) {
3148
2882
  className: "frame-media",
3149
2883
  src: media.url,
3150
2884
  poster: poster ? media.posterUrl : void 0,
3151
- onPlaying: () => setPlayingUrl(media.url),
3152
- onError: () => setPlayingUrl(null),
2885
+ style: appearance,
2886
+ onPlaying: ready,
2887
+ onError: () => {
2888
+ setPlayingUrl(null);
2889
+ onError?.();
2890
+ },
3153
2891
  autoPlay: playing !== false,
3154
2892
  muted: true,
3155
2893
  loop: true,
@@ -3158,7 +2896,7 @@ function Frame({ media, poster, playing }) {
3158
2896
  "aria-hidden": "true"
3159
2897
  }
3160
2898
  ),
3161
- poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx4("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "" })
2899
+ poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx4("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3162
2900
  ] });
3163
2901
  }
3164
2902
  function SuggestionCards({ suggestions, label, onAsk }) {
@@ -3231,8 +2969,41 @@ function Welcome({ data, onAsk, title }) {
3231
2969
  ] });
3232
2970
  }
3233
2971
 
2972
+ // src/video-chat/opening-chapter.tsx
2973
+ import { useLayoutEffect, useRef as useRef5, useState as useState4 } from "react";
2974
+ import { jsx as jsx6 } from "react/jsx-runtime";
2975
+ function OpeningChapter({ title }) {
2976
+ const root = useRef5(null);
2977
+ const [size, setSize] = useState4({ width: 1080, height: 1080 });
2978
+ useLayoutEffect(() => {
2979
+ const element = root.current;
2980
+ if (!element) return;
2981
+ const measure = () => {
2982
+ const { width, height } = element.getBoundingClientRect();
2983
+ if (width > 0 && height > 0) setSize({ width, height });
2984
+ };
2985
+ measure();
2986
+ if (typeof ResizeObserver === "undefined") return;
2987
+ const observer = new ResizeObserver(measure);
2988
+ observer.observe(element);
2989
+ return () => observer.disconnect();
2990
+ }, []);
2991
+ return /* @__PURE__ */ jsx6("div", { ref: root, "data-opening-chapter": true, className: "opening-chapter", children: /* @__PURE__ */ jsx6(
2992
+ TitleSceneTemplate,
2993
+ {
2994
+ variables: { title },
2995
+ style: {},
2996
+ width: size.width,
2997
+ height: size.height,
2998
+ progress: 0.3,
2999
+ beatIntensity: 0,
3000
+ safeZone: { top: 0, right: 0, bottom: 0, left: 0 }
3001
+ }
3002
+ ) });
3003
+ }
3004
+
3234
3005
  // src/video-chat/use-voice-input.ts
3235
- import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3006
+ import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef6, useState as useState5 } from "react";
3236
3007
  function recognitionConstructor() {
3237
3008
  if (typeof window === "undefined") return void 0;
3238
3009
  const holder = window;
@@ -3302,14 +3073,14 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3302
3073
  return clip ? transcribeRecording(clip, signal, options) : "";
3303
3074
  }
3304
3075
  function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {}) {
3305
- const [supported, setSupported] = useState4(false);
3306
- const [listening, setListening] = useState4(false);
3307
- const [error, setError] = useState4();
3308
- const [thinking, setThinking] = useState4(false);
3309
- const recognitionRef = useRef5(void 0);
3310
- const useRecorderRef = useRef5(false);
3311
- const operationRef = useRef5(void 0);
3312
- const handlerRef = useRef5(onTranscript);
3076
+ const [supported, setSupported] = useState5(false);
3077
+ const [listening, setListening] = useState5(false);
3078
+ const [error, setError] = useState5();
3079
+ const [thinking, setThinking] = useState5(false);
3080
+ const recognitionRef = useRef6(void 0);
3081
+ const useRecorderRef = useRef6(false);
3082
+ const operationRef = useRef6(void 0);
3083
+ const handlerRef = useRef6(onTranscript);
3313
3084
  handlerRef.current = onTranscript;
3314
3085
  useEffect7(() => {
3315
3086
  setSupported(supportsVoiceInput(transcriptionAvailable));
@@ -3436,13 +3207,13 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3436
3207
  }
3437
3208
 
3438
3209
  // src/video-chat/use-immersive-controls.ts
3439
- import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef6, useState as useState5 } from "react";
3210
+ import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
3440
3211
  function useImmersiveControls(playing, pinned, hasCaptions = false) {
3441
- const [visible, setVisible] = useState5(true);
3442
- const hovered = useRef6(false);
3443
- const focused = useRef6(false);
3444
- const previousCaptions = useRef6(false);
3445
- const timer = useRef6(null);
3212
+ const [visible, setVisible] = useState6(true);
3213
+ const hovered = useRef7(false);
3214
+ const focused = useRef7(false);
3215
+ const previousCaptions = useRef7(false);
3216
+ const timer = useRef7(null);
3446
3217
  const clearTimer = useCallback5(() => {
3447
3218
  if (timer.current !== null) clearTimeout(timer.current);
3448
3219
  timer.current = null;
@@ -3493,16 +3264,16 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3493
3264
  }
3494
3265
 
3495
3266
  // src/video-chat/logo.tsx
3496
- import { jsx as jsx6 } from "react/jsx-runtime";
3267
+ import { jsx as jsx7 } from "react/jsx-runtime";
3497
3268
  function Logo() {
3498
- return /* @__PURE__ */ jsx6("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
3269
+ return /* @__PURE__ */ jsx7("img", { className: "brand-logo", src: new URL("./assets/vanillasky-logo.svg", import.meta.url).href, alt: "VanillaSky", width: 144, height: 36 });
3499
3270
  }
3500
3271
 
3501
3272
  // src/video-chat/video-chat.tsx
3502
- import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3273
+ import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3503
3274
  var DESKTOP_WIDTH = 900;
3504
3275
  function useViewportOrientation() {
3505
- const [portrait, setPortrait] = useState6(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
3276
+ const [portrait, setPortrait] = useState7(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`).matches : false);
3506
3277
  useEffect9(() => {
3507
3278
  if (typeof window.matchMedia !== "function") return;
3508
3279
  const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`);
@@ -3520,7 +3291,7 @@ var STEPS = [
3520
3291
  "Still filming\u2026"
3521
3292
  ];
3522
3293
  function useFilmingStep(active, key) {
3523
- const [step, setStep] = useState6(0);
3294
+ const [step, setStep] = useState7(0);
3524
3295
  useEffect9(() => {
3525
3296
  if (!active) return;
3526
3297
  setStep(0);
@@ -3530,47 +3301,38 @@ function useFilmingStep(active, key) {
3530
3301
  return STEPS[Math.min(step, STEPS.length - 1)];
3531
3302
  }
3532
3303
  function Waveform({ active, listening }) {
3533
- return /* @__PURE__ */ jsx7("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx7("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3304
+ return /* @__PURE__ */ jsx8("span", { className: `waveform${active ? " on" : ""}${listening ? " hearing" : ""}`, "aria-hidden": "true", children: [0, 1, 2, 3, 4].map((bar) => /* @__PURE__ */ jsx8("span", { style: { animationDelay: `${bar * 140}ms`, animationDuration: `${900 + bar * 130}ms` } }, bar)) });
3534
3305
  }
3535
- function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel, generatedVideoDescription, showRecoveryNotice = false }) {
3536
- const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState6();
3537
- const [draft, setDraft] = useState6("");
3538
- const [savedSessions, setSavedSessions] = useState6([]);
3539
- const [themeId, setThemeId] = useState6(defaultTheme.id);
3540
- const [modeId, setModeId] = useState6(options.mode ?? defaultMode.id);
3541
- const [historyOpen, setHistoryOpen] = useState6(false);
3542
- const [settingsOpen, setSettingsOpen] = useState6(false);
3543
- const [aboutOpen, setAboutOpen] = useState6(false);
3544
- const [captionsOn, setCaptionsOn] = useState6(true);
3545
- const [captionsExpanded, setCaptionsExpanded] = useState6(false);
3546
- const [alwaysShowControls, setAlwaysShowControls] = useState6(false);
3547
- const [editing, setEditing] = useState6(false);
3548
- const resumeAfterInput = useRef7(false);
3549
- const panelRef = useRef7(null);
3550
- const composerRef = useRef7(null);
3306
+ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice = false }) {
3307
+ const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState7();
3308
+ const [draft, setDraft] = useState7("");
3309
+ const [savedSessions, setSavedSessions] = useState7([]);
3310
+ const [historyOpen, setHistoryOpen] = useState7(false);
3311
+ const [settingsOpen, setSettingsOpen] = useState7(false);
3312
+ const [aboutOpen, setAboutOpen] = useState7(false);
3313
+ const [captionsOn, setCaptionsOn] = useState7(true);
3314
+ const [captionsExpanded, setCaptionsExpanded] = useState7(false);
3315
+ const [alwaysShowControls, setAlwaysShowControls] = useState7(false);
3316
+ const [editing, setEditing] = useState7(false);
3317
+ const resumeAfterInput = useRef8(false);
3318
+ const panelRef = useRef8(null);
3319
+ const composerRef = useRef8(null);
3551
3320
  const viewportOrientation = useViewportOrientation();
3552
- const theme = themeById(themeId);
3553
3321
  const sessionOrientation = options.orientation ?? viewportOrientation;
3554
- const { brand: configuredBrand, style: configuredStyle, ...sessionOptions } = options;
3555
- const presentationBrand = configuredBrand ?? theme.brand;
3556
3322
  const { chat, restoreSession } = useVideoChatSession({
3557
- ...sessionOptions,
3558
- mode: modeId,
3323
+ ...options,
3559
3324
  orientation: sessionOrientation,
3560
- brand: presentationBrand,
3561
- style: { generatedLook: theme.generatedLook, ...configuredStyle }
3325
+ style: { generatedLook: "Natural light, restrained camera movement, documentary realism, consistent natural color. No embedded text.", ...options.style }
3562
3326
  });
3563
3327
  const instanceId = useId();
3564
3328
  const historyId = `${instanceId}-history`;
3565
3329
  const settingsId = `${instanceId}-settings`;
3566
3330
  const promptId = `${instanceId}-prompt`;
3567
- const visualsName = `${instanceId}-visuals`;
3568
- const styleName = `${instanceId}-style`;
3569
- const inputRef = useRef7(null);
3570
- const historyRef = useRef7(null);
3571
- const historyButtonRef = useRef7(null);
3572
- const settingsRef = useRef7(null);
3573
- const settingsButtonRef = useRef7(null);
3331
+ const inputRef = useRef8(null);
3332
+ const historyRef = useRef8(null);
3333
+ const historyButtonRef = useRef8(null);
3334
+ const settingsRef = useRef8(null);
3335
+ const settingsButtonRef = useRef8(null);
3574
3336
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3575
3337
  endpoint: options.endpoint,
3576
3338
  headers: options.headers,
@@ -3623,17 +3385,19 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3623
3385
  const current = chat.currentTurn;
3624
3386
  const shown = chat.shownTurn;
3625
3387
  const showing = chat.playerProps != null;
3626
- const ambientPoster = shown?.openingMedia?.type === "image" ? shown.openingMedia.url : shown?.openingMedia?.posterUrl;
3388
+ const [openingAsset, setOpeningAsset] = useState7();
3389
+ const openingAssetKey = `${shown?.id ?? ""}:${shown?.openingMedia?.url ?? ""}`;
3390
+ const openingMediaFailed = openingAsset?.key === openingAssetKey && openingAsset.state === "failed";
3391
+ const openingMediaReady = openingAsset?.key === openingAssetKey && openingAsset.state === "ready";
3392
+ const openingChapter = !showing && Boolean(shown?.opening) && (!shown?.openingMedia || openingMediaFailed || !openingMediaReady);
3627
3393
  const waitingForPicture = chat.turns.length > 0 && !showing && (chat.status === "composing" || chat.status === "playing" || chat.status === "paused");
3628
3394
  const filmingStep = useFilmingStep(waitingForPicture, current?.id);
3629
3395
  const status = chat.turns.length === 0 ? "idle" : chat.status === "composing" ? "drawing" : chat.status === "playing" ? "narrating" : chat.status === "error" || chat.status === "cancelled" ? "ended" : chat.status;
3630
3396
  const shownOrientation = shown?.orientation ?? sessionOrientation;
3631
3397
  const stageOrientation = shown?.fixedOrientation ? shownOrientation : sessionOrientation;
3632
- const availableModes = visualModes.filter((option) => chat.availableModes.includes(option.id));
3633
- const selectedMode = modeById(modeId);
3634
3398
  const line = chat.caption ?? "";
3635
3399
  const fullTranscript = shown?.video ? [shown.opening, ...shown.video.scenes.map((scene) => scene.narration)].filter((entry) => Boolean(entry)) : chat.transcript;
3636
- const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx7(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx7(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx7(Replay, {}) } : void 0;
3400
+ const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx8(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx8(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx8(Replay, {}) } : void 0;
3637
3401
  const cancelInput = useCallback6(() => {
3638
3402
  listen.stop();
3639
3403
  setDraft("");
@@ -3664,7 +3428,7 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3664
3428
  onFocusCapture: controls.onFocusCapture,
3665
3429
  onBlurCapture: controls.onBlurCapture
3666
3430
  };
3667
- useLayoutEffect(() => {
3431
+ useLayoutEffect2(() => {
3668
3432
  const composer = composerRef.current;
3669
3433
  if (!composer) return;
3670
3434
  const measure = () => panelRef.current?.style.setProperty("--composer-height", `${composer.getBoundingClientRect().height}px`);
@@ -3687,7 +3451,7 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3687
3451
  onKeyDownCapture: controls.reveal,
3688
3452
  children: [
3689
3453
  /* @__PURE__ */ jsxs6("header", { className: "chrome", ...controlEvents, children: [
3690
- /* @__PURE__ */ jsx7("div", { className: "session-brand", children: /* @__PURE__ */ jsx7(Logo, {}) }),
3454
+ /* @__PURE__ */ jsx8("div", { className: "session-brand", children: /* @__PURE__ */ jsx8(Logo, {}) }),
3691
3455
  /* @__PURE__ */ jsxs6("div", { className: "group", children: [
3692
3456
  /* @__PURE__ */ jsxs6(
3693
3457
  "button",
@@ -3704,12 +3468,12 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3704
3468
  setSettingsOpen(false);
3705
3469
  },
3706
3470
  children: [
3707
- /* @__PURE__ */ jsx7(Sessions, {}),
3708
- /* @__PURE__ */ jsx7("span", { className: "nav-label", children: "Sessions" })
3471
+ /* @__PURE__ */ jsx8(Sessions, {}),
3472
+ /* @__PURE__ */ jsx8("span", { className: "nav-label", children: "Sessions" })
3709
3473
  ]
3710
3474
  }
3711
3475
  ),
3712
- /* @__PURE__ */ jsx7(
3476
+ /* @__PURE__ */ jsx8(
3713
3477
  "button",
3714
3478
  {
3715
3479
  ref: settingsButtonRef,
@@ -3723,10 +3487,10 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3723
3487
  setSettingsOpen((open) => !open);
3724
3488
  setHistoryOpen(false);
3725
3489
  },
3726
- children: /* @__PURE__ */ jsx7(Gear, {})
3490
+ children: /* @__PURE__ */ jsx8(Gear, {})
3727
3491
  }
3728
3492
  ),
3729
- /* @__PURE__ */ jsx7(
3493
+ /* @__PURE__ */ jsx8(
3730
3494
  "button",
3731
3495
  {
3732
3496
  type: "button",
@@ -3734,27 +3498,37 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3734
3498
  "aria-label": chat.muted ? "Turn the voice on" : "Turn the voice off",
3735
3499
  "aria-pressed": chat.muted,
3736
3500
  onClick: () => chat.setMuted(!chat.muted),
3737
- children: chat.muted ? /* @__PURE__ */ jsx7(Muted, {}) : /* @__PURE__ */ jsx7(Sound, {})
3501
+ children: chat.muted ? /* @__PURE__ */ jsx8(Muted, {}) : /* @__PURE__ */ jsx8(Sound, {})
3738
3502
  }
3739
3503
  )
3740
3504
  ] })
3741
3505
  ] }),
3742
3506
  /* @__PURE__ */ jsxs6("div", { className: "stage-area", children: [
3743
- /* @__PURE__ */ jsxs6("div", { className: "stage", style: { background: themeBackground(presentationBrand) }, children: [
3744
- showing && /* @__PURE__ */ jsx7("div", { className: "ambient-media", "aria-hidden": "true", children: ambientPoster && /* @__PURE__ */ jsx7("img", { className: "frame-media", src: ambientPoster, alt: "" }) }),
3507
+ /* @__PURE__ */ jsxs6("div", { className: "stage", style: { background: "#000" }, children: [
3745
3508
  !showing && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3746
- /* @__PURE__ */ jsx7("div", { className: "ground", "aria-hidden": "true" }),
3747
- shown?.openingMedia && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3748
- /* @__PURE__ */ jsx7(Frame, { media: shown.openingMedia, poster: true }),
3749
- /* @__PURE__ */ jsx7("div", { className: "opening-wash", "aria-hidden": "true" })
3509
+ /* @__PURE__ */ jsx8("div", { className: "ground", "aria-hidden": "true" }),
3510
+ openingChapter && /* @__PURE__ */ jsx8(OpeningChapter, { title: shown.opening }, shown.id),
3511
+ shown?.openingMedia && !openingMediaFailed && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3512
+ /* @__PURE__ */ jsx8(
3513
+ Frame,
3514
+ {
3515
+ media: shown.openingMedia,
3516
+ poster: true,
3517
+ revealWhenReady: true,
3518
+ onReady: () => setOpeningAsset({ key: openingAssetKey, state: "ready" }),
3519
+ onError: () => setOpeningAsset({ key: openingAssetKey, state: "failed" })
3520
+ },
3521
+ openingAssetKey
3522
+ ),
3523
+ openingMediaReady && /* @__PURE__ */ jsx8("div", { className: "opening-wash", "aria-hidden": "true" })
3750
3524
  ] }),
3751
- chat.turns.length === 0 && /* @__PURE__ */ jsx7(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3752
- shown?.prompt && /* @__PURE__ */ jsxs6("div", { className: "asked", children: [
3753
- /* @__PURE__ */ jsx7("p", { className: "asked-prompt", children: shown.prompt }),
3754
- waitingForPicture && /* @__PURE__ */ jsx7("p", { className: "asked-step", "aria-live": "polite", children: filmingStep })
3525
+ chat.turns.length === 0 && /* @__PURE__ */ jsx8(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3526
+ shown?.prompt && !shown.opening && /* @__PURE__ */ jsxs6("div", { className: "asked", children: [
3527
+ /* @__PURE__ */ jsx8("p", { className: "asked-prompt", children: shown.prompt }),
3528
+ waitingForPicture && /* @__PURE__ */ jsx8("p", { className: "asked-step", "aria-live": "polite", children: filmingStep })
3755
3529
  ] })
3756
3530
  ] }),
3757
- chat.playerProps && /* @__PURE__ */ jsx7("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx7(
3531
+ chat.playerProps && /* @__PURE__ */ jsx8("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx8(
3758
3532
  VideoPlayer,
3759
3533
  {
3760
3534
  ...chat.playerProps,
@@ -3766,27 +3540,27 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3766
3540
  chat.playerKey
3767
3541
  ) }),
3768
3542
  showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs6("div", { className: "ending", children: [
3769
- /* @__PURE__ */ jsx7("div", { className: "ending-wash", "aria-hidden": "true" }),
3543
+ /* @__PURE__ */ jsx8("div", { className: "ending-wash", "aria-hidden": "true" }),
3770
3544
  /* @__PURE__ */ jsxs6("div", { className: "ending-body", children: [
3771
- /* @__PURE__ */ jsx7("p", { className: "ending-label", children: "Ask next" }),
3772
- /* @__PURE__ */ jsx7(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3545
+ /* @__PURE__ */ jsx8("p", { className: "ending-label", children: "Ask next" }),
3546
+ /* @__PURE__ */ jsx8(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3773
3547
  ] })
3774
3548
  ] })
3775
3549
  ] }),
3776
3550
  historyOpen && /* @__PURE__ */ jsxs6("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "Sessions", children: [
3777
3551
  /* @__PURE__ */ jsxs6("div", { className: "popover-heading", children: [
3778
- /* @__PURE__ */ jsx7("h2", { children: "Sessions" }),
3779
- /* @__PURE__ */ jsx7("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx7(Close, {}) })
3552
+ /* @__PURE__ */ jsx8("h2", { children: "Sessions" }),
3553
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx8(Close, {}) })
3780
3554
  ] }),
3781
3555
  /* @__PURE__ */ jsxs6("button", { type: "button", className: "history-row session-new", "aria-label": "New session", onClick: newSession, children: [
3782
- /* @__PURE__ */ jsx7(Plus, {}),
3556
+ /* @__PURE__ */ jsx8(Plus, {}),
3783
3557
  /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3784
3558
  "New session",
3785
- /* @__PURE__ */ jsx7("small", { children: "Start a fresh conversation" })
3559
+ /* @__PURE__ */ jsx8("small", { children: "Start a fresh conversation" })
3786
3560
  ] })
3787
3561
  ] }),
3788
- /* @__PURE__ */ jsx7("h3", { className: "section-label", children: "Current session" }),
3789
- chat.turns.length === 0 && /* @__PURE__ */ jsx7("p", { className: "history-empty", children: "Your questions will appear here." }),
3562
+ /* @__PURE__ */ jsx8("h3", { className: "section-label", children: "Current session" }),
3563
+ chat.turns.length === 0 && /* @__PURE__ */ jsx8("p", { className: "history-empty", children: "Your questions will appear here." }),
3790
3564
  chat.turns.map((turn, index) => /* @__PURE__ */ jsxs6(
3791
3565
  "button",
3792
3566
  {
@@ -3803,17 +3577,17 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3803
3577
  setHistoryOpen(false);
3804
3578
  },
3805
3579
  children: [
3806
- /* @__PURE__ */ jsx7("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3580
+ /* @__PURE__ */ jsx8("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3807
3581
  /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3808
3582
  turn.prompt,
3809
- /* @__PURE__ */ jsx7("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3583
+ /* @__PURE__ */ jsx8("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3810
3584
  ] })
3811
3585
  ]
3812
3586
  },
3813
3587
  turn.id
3814
3588
  )),
3815
3589
  savedSessions.length > 0 && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3816
- /* @__PURE__ */ jsx7("h3", { className: "section-label", children: "Earlier sessions" }),
3590
+ /* @__PURE__ */ jsx8("h3", { className: "section-label", children: "Earlier sessions" }),
3817
3591
  savedSessions.map((session) => /* @__PURE__ */ jsxs6("button", { type: "button", className: "history-row", onClick: () => {
3818
3592
  listen.stop();
3819
3593
  setDraft("");
@@ -3825,7 +3599,7 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3825
3599
  setHistoryOpen(false);
3826
3600
  setCaptionsExpanded(false);
3827
3601
  }, children: [
3828
- /* @__PURE__ */ jsx7(Replay, {}),
3602
+ /* @__PURE__ */ jsx8(Replay, {}),
3829
3603
  /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3830
3604
  session.turns[0]?.prompt,
3831
3605
  /* @__PURE__ */ jsxs6("small", { children: [
@@ -3848,72 +3622,55 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3848
3622
  "aria-modal": "true",
3849
3623
  children: [
3850
3624
  /* @__PURE__ */ jsxs6("div", { className: "popover-heading", children: [
3851
- /* @__PURE__ */ jsx7("h2", { children: "Settings" }),
3852
- /* @__PURE__ */ jsx7("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx7(Close, {}) })
3625
+ /* @__PURE__ */ jsx8("h2", { children: "Settings" }),
3626
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx8(Close, {}) })
3853
3627
  ] }),
3854
3628
  /* @__PURE__ */ jsxs6("fieldset", { className: "playback-options", children: [
3855
- /* @__PURE__ */ jsx7("legend", { children: "Watching" }),
3629
+ /* @__PURE__ */ jsx8("legend", { children: "Watching" }),
3856
3630
  /* @__PURE__ */ jsxs6("label", { className: "switch-row", children: [
3857
3631
  /* @__PURE__ */ jsxs6("span", { children: [
3858
- /* @__PURE__ */ jsx7("strong", { children: "Subtitles" }),
3859
- /* @__PURE__ */ jsx7("small", { children: "Read along with the answer" })
3632
+ /* @__PURE__ */ jsx8("strong", { children: "Subtitles" }),
3633
+ /* @__PURE__ */ jsx8("small", { children: "Read along with the answer" })
3860
3634
  ] }),
3861
- /* @__PURE__ */ jsx7("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3635
+ /* @__PURE__ */ jsx8("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3862
3636
  setCaptionsOn(event.target.checked);
3863
3637
  setCaptionsExpanded(false);
3864
3638
  } })
3865
3639
  ] }),
3866
3640
  /* @__PURE__ */ jsxs6("label", { className: "switch-row", children: [
3867
3641
  /* @__PURE__ */ jsxs6("span", { children: [
3868
- /* @__PURE__ */ jsx7("strong", { children: "Keep controls visible" }),
3869
- /* @__PURE__ */ jsx7("small", { children: "Keep the input bar on screen" })
3642
+ /* @__PURE__ */ jsx8("strong", { children: "Keep controls visible" }),
3643
+ /* @__PURE__ */ jsx8("small", { children: "Keep the input bar on screen" })
3870
3644
  ] }),
3871
- /* @__PURE__ */ jsx7("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3645
+ /* @__PURE__ */ jsx8("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3872
3646
  ] })
3873
3647
  ] }),
3874
- /* @__PURE__ */ jsxs6("fieldset", { className: "visual-options", children: [
3875
- /* @__PURE__ */ jsx7("legend", { children: "Video creation" }),
3876
- /* @__PURE__ */ jsx7("div", { className: "visual-choices", children: availableModes.map((option) => /* @__PURE__ */ jsxs6("label", { className: "choice-row", children: [
3877
- /* @__PURE__ */ jsx7("input", { type: "radio", name: visualsName, checked: option.id === selectedMode.id, onChange: () => setModeId(option.id) }),
3878
- /* @__PURE__ */ jsxs6("span", { children: [
3879
- /* @__PURE__ */ jsx7("strong", { children: option.id === "full" ? generatedVideoLabel ?? option.label : option.label }),
3880
- /* @__PURE__ */ jsx7("small", { children: option.id === "full" ? generatedVideoDescription ?? option.note : option.note })
3881
- ] })
3882
- ] }, option.id)) })
3883
- ] }),
3884
- /* @__PURE__ */ jsxs6("fieldset", { className: "style-options", children: [
3885
- /* @__PURE__ */ jsx7("legend", { children: "Video style" }),
3886
- /* @__PURE__ */ jsx7("div", { className: "style-choices", children: themes.map((option) => /* @__PURE__ */ jsxs6("label", { children: [
3887
- /* @__PURE__ */ jsx7("input", { type: "radio", name: styleName, checked: option.id === themeId, onChange: () => setThemeId(option.id) }),
3888
- /* @__PURE__ */ jsx7("span", { children: /* @__PURE__ */ jsx7("strong", { children: option.label }) })
3889
- ] }, option.id)) })
3890
- ] }),
3891
- /* @__PURE__ */ jsx7("p", { className: "settings-note", children: "Creation and style changes apply to your next question." }),
3892
3648
  /* @__PURE__ */ jsxs6("nav", { className: "developer-links", "aria-label": "Build with VanillaSky", children: [
3893
- /* @__PURE__ */ jsx7("p", { className: "section-label", children: "Build with VanillaSky" }),
3649
+ /* @__PURE__ */ jsx8("p", { className: "section-label", children: "Build with VanillaSky" }),
3894
3650
  /* @__PURE__ */ jsxs6("a", { href: "https://github.com/VanillaSkyAi/video/blob/main/docs/getting-started.md", target: "_blank", rel: "noopener noreferrer", children: [
3895
3651
  "Docs",
3896
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\u2197" })
3652
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2197" })
3897
3653
  ] }),
3898
3654
  /* @__PURE__ */ jsxs6("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
3899
3655
  "About",
3900
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3656
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3901
3657
  ] }),
3902
- /* @__PURE__ */ jsx7("div", { id: `${instanceId}-about`, className: "developer-about", role: "region", "aria-label": "About VanillaSky", hidden: !aboutOpen, children: /* @__PURE__ */ jsx7("p", { children: "VanillaSky is an open-source SDK for conversations that answer in video. Developers connect their own AI providers through their application server." }) }),
3658
+ /* @__PURE__ */ jsx8("div", { id: `${instanceId}-about`, className: "developer-about", role: "region", "aria-label": "About VanillaSky", hidden: !aboutOpen, children: /* @__PURE__ */ jsx8("p", { children: "VanillaSky is an open-source SDK for conversations that answer in video. Developers connect their own AI providers through their application server." }) }),
3903
3659
  /* @__PURE__ */ jsxs6("a", { href: "https://github.com/VanillaSkyAi/video", target: "_blank", rel: "noopener noreferrer", children: [
3904
3660
  "GitHub",
3905
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\u2197" })
3661
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2197" })
3906
3662
  ] })
3907
3663
  ] })
3908
3664
  ]
3909
3665
  }
3910
3666
  )
3911
3667
  ] }),
3912
- /* @__PURE__ */ jsx7("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs6("div", { className: "panel-inner", children: [
3913
- /* @__PURE__ */ jsx7("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx7("div", { className: "caption-clip", children: /* @__PURE__ */ jsxs6(
3668
+ /* @__PURE__ */ jsx8("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs6("div", { className: "panel-inner", children: [
3669
+ /* @__PURE__ */ jsx8("div", { className: "caption-slot", "data-captions": captionsOn && Boolean(line), "aria-hidden": !captionsOn || !line, children: /* @__PURE__ */ jsx8("div", { className: "caption-clip", children: /* @__PURE__ */ jsxs6(
3914
3670
  "div",
3915
3671
  {
3916
3672
  className: "line-row",
3673
+ "data-opening-copy": openingChapter && line === shown?.opening && !captionsExpanded,
3917
3674
  "data-expanded": captionsExpanded,
3918
3675
  "data-actions-visible": captionControls.visible,
3919
3676
  onPointerMove: captionControls.onPointerEnter,
@@ -3925,41 +3682,41 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3925
3682
  captionsOn && line && /* @__PURE__ */ jsxs6("div", { className: "caption-actions", children: [
3926
3683
  /* @__PURE__ */ jsxs6("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
3927
3684
  captionsExpanded ? "Collapse" : "Expand",
3928
- /* @__PURE__ */ jsx7(ChevronUp, {})
3685
+ /* @__PURE__ */ jsx8(ChevronUp, {})
3929
3686
  ] }),
3930
- /* @__PURE__ */ jsx7("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
3687
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
3931
3688
  setCaptionsOn(false);
3932
3689
  setCaptionsExpanded(false);
3933
- }, children: /* @__PURE__ */ jsx7(Close, {}) })
3690
+ }, children: /* @__PURE__ */ jsx8(Close, {}) })
3934
3691
  ] }),
3935
- captionsExpanded ? /* @__PURE__ */ jsx7("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx7("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx7("p", { className: "line", "aria-live": "polite", children: line })
3692
+ captionsExpanded ? /* @__PURE__ */ jsx8("div", { className: "expanded-captions", role: "region", tabIndex: 0, "aria-label": "Expanded subtitles", children: fullTranscript.map((entry, index) => /* @__PURE__ */ jsx8("p", { children: entry }, index)) }) : /* @__PURE__ */ jsx8("p", { className: "line", "aria-live": "polite", children: openingChapter && line === shown?.opening ? "" : line })
3936
3693
  ]
3937
3694
  }
3938
3695
  ) }) }),
3939
3696
  showRecoveryNotice && chat.shownTurn && dismissedNoticeTurn !== chat.shownTurn.id && chat.warnings.includes(MEDIA_RECOVERY_NOTICE) && !chat.error && /* @__PURE__ */ jsxs6("div", { className: "recovery-notice", children: [
3940
- /* @__PURE__ */ jsx7("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
3941
- /* @__PURE__ */ jsx7("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx7(Close, {}) })
3697
+ /* @__PURE__ */ jsx8("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
3698
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx8(Close, {}) })
3942
3699
  ] }),
3943
3700
  (chat.error || listen.error) && /* @__PURE__ */ jsxs6("p", { className: "error", role: "status", children: [
3944
- /* @__PURE__ */ jsx7(Warning, {}),
3945
- /* @__PURE__ */ jsx7("span", { children: chat.error?.message ?? listen.error })
3701
+ /* @__PURE__ */ jsx8(Warning, {}),
3702
+ /* @__PURE__ */ jsx8("span", { children: chat.error?.message ?? listen.error })
3946
3703
  ] }),
3947
3704
  /* @__PURE__ */ jsxs6("div", { ref: composerRef, className: "conversation-composer", "data-editing": editing, ...controlEvents, children: [
3948
- (listen.listening || listen.thinking) && /* @__PURE__ */ jsx7("div", { className: "composer-meta", children: /* @__PURE__ */ jsx7("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
3705
+ (listen.listening || listen.thinking) && /* @__PURE__ */ jsx8("div", { className: "composer-meta", children: /* @__PURE__ */ jsx8("span", { role: "status", children: listen.listening ? "Listening\u2026 Tap the mic to finish, then review and send." : "Turning your words into a draft\u2026" }) }),
3949
3706
  /* @__PURE__ */ jsxs6("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
3950
3707
  event.preventDefault();
3951
3708
  ask(draft);
3952
3709
  }, children: [
3953
- transport && /* @__PURE__ */ jsx7("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
3710
+ transport && /* @__PURE__ */ jsx8("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
3954
3711
  listen.stop();
3955
3712
  setEditing(false);
3956
3713
  resumeAfterInput.current = false;
3957
3714
  inputRef.current?.blur();
3958
3715
  transport.action();
3959
3716
  }, children: transport.icon }),
3960
- /* @__PURE__ */ jsx7(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
3961
- /* @__PURE__ */ jsx7("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
3962
- /* @__PURE__ */ jsx7(
3717
+ /* @__PURE__ */ jsx8(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
3718
+ /* @__PURE__ */ jsx8("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
3719
+ /* @__PURE__ */ jsx8(
3963
3720
  "textarea",
3964
3721
  {
3965
3722
  id: promptId,
@@ -3987,12 +3744,12 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3987
3744
  }
3988
3745
  }
3989
3746
  ),
3990
- listen.supported && /* @__PURE__ */ jsx7("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__ */ jsx7(Mic, {}) }),
3991
- /* @__PURE__ */ jsx7("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx7(Send, {}) }),
3992
- editing && /* @__PURE__ */ jsx7("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx7(Close, {}) })
3747
+ listen.supported && /* @__PURE__ */ jsx8("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__ */ jsx8(Mic, {}) }),
3748
+ /* @__PURE__ */ jsx8("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx8(Send, {}) }),
3749
+ editing && /* @__PURE__ */ jsx8("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx8(Close, {}) })
3993
3750
  ] })
3994
3751
  ] }),
3995
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx7("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
3752
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx8("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
3996
3753
  ] }) })
3997
3754
  ]
3998
3755
  }