@vanillaskyai/video 0.8.8 → 0.10.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 (173) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/PUBLIC-API.md +5 -8
  3. package/dist/{builtin-server-CUYPIDE7.js → builtin-server-W4PLJUZ5.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 +5 -14
  7. package/dist/{chunk-AITKH6QT.js → chunk-2HFO22K2.js} +15 -40
  8. package/dist/chunk-4YM2M62S.js +13 -0
  9. package/dist/chunk-4ZJLPHBV.js +684 -0
  10. package/dist/chunk-77VY4O7A.js +390 -0
  11. package/dist/{chunk-MMUXVA47.js → chunk-7M56IUUX.js} +9 -8
  12. package/dist/chunk-7NZOMTAL.js +63 -0
  13. package/dist/{chunk-2DOLF4QN.js → chunk-AUPC6MDK.js} +12 -14
  14. package/dist/{chunk-352ZZCET.js → chunk-EGVQODKU.js} +2 -2
  15. package/dist/{chunk-2USVB3CY.js → chunk-ELVOT2FQ.js} +284 -177
  16. package/dist/chunk-EWWCQFTI.js +18 -0
  17. package/dist/{chunk-KT3M7KDL.js → chunk-HKUI5BTB.js} +3 -3
  18. package/dist/chunk-IIN5M5HW.js +697 -0
  19. package/dist/{chunk-LMG3GHLL.js → chunk-IQMYK5DX.js} +29 -3
  20. package/dist/{chunk-OWBT45EN.js → chunk-IR44XKBI.js} +1 -1
  21. package/dist/{chunk-MXZSDGZQ.js → chunk-NMFDC2W2.js} +4 -6
  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-XX4GXKTX.js +35 -0
  28. package/dist/chunk-Z67Q5KUW.js +16 -0
  29. package/dist/{chunk-3VRMKFM7.js → chunk-ZDJNFA52.js} +11 -24
  30. package/dist/cinema-media-C7OUPNSH.js +12 -0
  31. package/dist/cli.js +18 -9
  32. package/dist/comparison-GNZOR7MF.js +37 -0
  33. package/dist/compose-video-TBFTONUC.js +12 -0
  34. package/dist/editorial-timeline-EMV5335S.js +43 -0
  35. package/dist/{events-BQ6z264n.d.ts → events-B4YCc4vc.d.ts} +1 -1
  36. package/dist/index.d.ts +4 -6
  37. package/dist/index.js +3 -7
  38. package/dist/key-figure-P37VK425.js +30 -0
  39. package/dist/{kit-tQnCPMOM.d.ts → kit-DrRpdn0p.d.ts} +2 -2
  40. package/dist/mobile-message-OF7T62B7.js +50 -0
  41. package/dist/quote-T6WYLN7A.js +33 -0
  42. package/dist/react.d.ts +21 -11
  43. package/dist/react.js +585 -826
  44. package/dist/scene-video-backdrop-PCTARAAJ.js +7 -0
  45. package/dist/server.d.ts +7 -8
  46. package/dist/server.js +179 -84
  47. package/dist/{system-prompt-RRXIWDDD.js → system-prompt-SYRT5DWM.js} +1 -1
  48. package/dist/template-catalog.d.ts +392 -16
  49. package/dist/template-catalog.js +1 -1
  50. package/dist/templates.d.ts +5 -5
  51. package/dist/test.d.ts +3 -3
  52. package/dist/test.js +18 -18
  53. package/dist/text-stream-LD364KBI.js +9 -0
  54. package/dist/{types-DWFO6qca.d.ts → types-CdBmNTGD.d.ts} +1 -1
  55. package/dist/{types-CEh9eUFu.d.ts → types-DdZw4GRQ.d.ts} +17 -48
  56. package/dist/validate-4OUD2CLX.js +10 -0
  57. package/docs/architecture.md +1 -1
  58. package/docs/concepts.md +1 -1
  59. package/docs/custom-templates.md +13 -28
  60. package/docs/customization.md +11 -59
  61. package/docs/getting-started.md +2 -2
  62. package/docs/immersive-interface.md +3 -4
  63. package/docs/maintainers/cinematic-migration.md +38 -0
  64. package/docs/media-and-audio.md +14 -14
  65. package/docs/motion-and-effects.md +6 -14
  66. package/docs/persistence.md +3 -3
  67. package/docs/production.md +11 -9
  68. package/docs/provider-integration.md +9 -16
  69. package/docs/reference/design-system.html +1 -1
  70. package/docs/reference/protocol.md +14 -4
  71. package/docs/testing.md +1 -1
  72. package/package.json +6 -3
  73. package/registry/items/backgrounds.json +1 -1
  74. package/registry/items/chapterTitle.json +80 -0
  75. package/registry/items/cinemaMedia.json +136 -0
  76. package/registry/items/comparison.json +168 -0
  77. package/registry/items/editorialTimeline.json +172 -0
  78. package/registry/items/keyFigure.json +162 -0
  79. package/registry/items/mobileMessage.json +153 -0
  80. package/registry/items/quote.json +161 -0
  81. package/registry/items/template-context.json +1 -1
  82. package/registry/items/theme.json +1 -1
  83. package/starters/video-chat/README.md +24 -26
  84. package/starters/video-chat/package.json +1 -1
  85. package/starters/video-chat/providers/video.ts +6 -5
  86. package/starters/video-chat/stock.ts +35 -151
  87. package/styles/fonts/OFL.txt +93 -0
  88. package/styles/fonts/manifest.json +66 -0
  89. package/styles/fonts/roboto-v51-cyrillic-ext.woff2 +0 -0
  90. package/styles/fonts/roboto-v51-cyrillic.woff2 +0 -0
  91. package/styles/fonts/roboto-v51-greek-ext.woff2 +0 -0
  92. package/styles/fonts/roboto-v51-greek.woff2 +0 -0
  93. package/styles/fonts/roboto-v51-latin-ext.woff2 +0 -0
  94. package/styles/fonts/roboto-v51-latin.woff2 +0 -0
  95. package/styles/fonts/roboto-v51-math.woff2 +0 -0
  96. package/styles/fonts/roboto-v51-symbols.woff2 +0 -0
  97. package/styles/fonts/roboto-v51-vietnamese.woff2 +0 -0
  98. package/styles/fonts/roboto.css +91 -0
  99. package/styles/video-chat.css +30 -22
  100. package/dist/bg-confetti-HITCGLGD.js +0 -89
  101. package/dist/bg-emoji-VU4UZYZN.js +0 -150
  102. package/dist/bg-media-L34PDQXJ.js +0 -106
  103. package/dist/brand-message-FUJ4STFO.js +0 -256
  104. package/dist/chart-bar-EGPHJATL.js +0 -310
  105. package/dist/chart-counter-5FFWQNZO.js +0 -306
  106. package/dist/chart-progress-ring-JMXQLMLC.js +0 -302
  107. package/dist/chunk-4UNKZEAT.js +0 -3575
  108. package/dist/chunk-5CDAM24P.js +0 -31
  109. package/dist/chunk-AOWSXU2K.js +0 -18
  110. package/dist/chunk-CJ7F2CE6.js +0 -104
  111. package/dist/chunk-FRN6WKHA.js +0 -185
  112. package/dist/chunk-G5JPHBX7.js +0 -336
  113. package/dist/chunk-L5BTTCZV.js +0 -97
  114. package/dist/chunk-P22HHFDK.js +0 -43
  115. package/dist/chunk-PP3MCPN4.js +0 -1998
  116. package/dist/chunk-QAAO7OAY.js +0 -19
  117. package/dist/chunk-S7HFLYNN.js +0 -35
  118. package/dist/chunk-W3VDIBGR.js +0 -217
  119. package/dist/chunk-XGRA2MUV.js +0 -242
  120. package/dist/chunk-XTOXFMLM.js +0 -105
  121. package/dist/chunk-YJJC4N4D.js +0 -761
  122. package/dist/compose-video-2NFH4DZY.js +0 -13
  123. package/dist/cta-logo-YR7LYUR5.js +0 -212
  124. package/dist/cta-media-DQJBKUO2.js +0 -221
  125. package/dist/incoming-call-54KQ7O5A.js +0 -323
  126. package/dist/infographic-before-after-ROS52GOW.js +0 -460
  127. package/dist/infographic-feature-list-E7MGUDAN.js +0 -272
  128. package/dist/infographic-problem-solution-3S6NGO5N.js +0 -343
  129. package/dist/infographic-stat-row-SYB7AB7H.js +0 -247
  130. package/dist/infographic-steps-KCE6JWE2.js +0 -406
  131. package/dist/prompt-input-MHX4O42G.js +0 -239
  132. package/dist/reaction-SLEGR3BE.js +0 -122
  133. package/dist/scene-video-backdrop-XFPX4O3I.js +0 -10
  134. package/dist/showcase-code-2HLAVKBM.js +0 -357
  135. package/dist/showcase-phone-Q4HEO4LH.js +0 -278
  136. package/dist/showcase-terminal-DL45LUFG.js +0 -312
  137. package/dist/showcase-web-2QZO73FZ.js +0 -396
  138. package/dist/social-conversation-75SD2637.js +0 -695
  139. package/dist/social-milestone-GEFUQSBF.js +0 -296
  140. package/dist/social-notification-FJ5VHVLK.js +0 -247
  141. package/dist/social-review-stack-KXQCKFRP.js +0 -242
  142. package/dist/social-testimonial-JZ7CHC7S.js +0 -287
  143. package/dist/social-tweet-UCDXM25F.js +0 -298
  144. package/dist/text-stream-54LXABJW.js +0 -10
  145. package/dist/validate-T7GBU2YF.js +0 -8
  146. package/registry/items/barChart.json +0 -245
  147. package/registry/items/beforeAfter.json +0 -180
  148. package/registry/items/bigNumber.json +0 -232
  149. package/registry/items/brandMessage.json +0 -164
  150. package/registry/items/cardList.json +0 -214
  151. package/registry/items/chatMessenger.json +0 -151
  152. package/registry/items/chatWhatsapp.json +0 -151
  153. package/registry/items/codeEditor.json +0 -191
  154. package/registry/items/confetti.json +0 -107
  155. package/registry/items/ctaLogo.json +0 -167
  156. package/registry/items/ctaMedia.json +0 -200
  157. package/registry/items/emojiBurst.json +0 -101
  158. package/registry/items/incomingCall.json +0 -177
  159. package/registry/items/media.json +0 -187
  160. package/registry/items/milestone.json +0 -212
  161. package/registry/items/notification.json +0 -189
  162. package/registry/items/phoneMockup.json +0 -261
  163. package/registry/items/problemSolution.json +0 -193
  164. package/registry/items/progressRing.json +0 -206
  165. package/registry/items/promptInput.json +0 -155
  166. package/registry/items/reaction.json +0 -176
  167. package/registry/items/reviewStack.json +0 -231
  168. package/registry/items/steps.json +0 -215
  169. package/registry/items/terminal.json +0 -219
  170. package/registry/items/testimonial.json +0 -171
  171. package/registry/items/tripleStats.json +0 -235
  172. package/registry/items/tweet.json +0 -210
  173. 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-IIN5M5HW.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";
41
+ getDimensions,
42
+ sceneReadinessKey
43
+ } from "./chunk-ELVOT2FQ.js";
36
44
  import "./chunk-224QNWRA.js";
37
- import "./chunk-5CDAM24P.js";
38
- import {
39
- fontStack
40
- } from "./chunk-XWWLKRNU.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,98 @@ 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-C7OUPNSH.js").then((module) => ({ default: module.MediaSceneTemplate })),
97
+ "chapterTitle": () => import("./chapter-title-YRLIQY3V.js").then((module) => ({ default: module.TitleSceneTemplate })),
98
+ "editorialTimeline": () => import("./editorial-timeline-EMV5335S.js").then((module) => ({ default: module.TimelineSceneTemplate })),
99
+ "mobileMessage": () => import("./mobile-message-OF7T62B7.js").then((module) => ({ default: module.NotificationSceneTemplate })),
100
+ "comparison": () => import("./comparison-GNZOR7MF.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
101
+ "quote": () => import("./quote-T6WYLN7A.js").then((module) => ({ default: module.QuoteSceneTemplate })),
102
+ "keyFigure": () => import("./key-figure-P37VK425.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
120
103
  };
121
104
 
122
105
  // src/visual-system/catalog/builtin-player.generated.ts
123
106
  var GENERATED_BUILTIN_PLAYER_TEMPLATES = [
124
107
  {
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",
108
+ "id": "cinemaMedia",
144
109
  "defaults": {
145
- "texts": "Still editing manually?",
146
- "reactionTag": "manual",
147
110
  "mediaUrl": "",
148
111
  "mediaType": "video",
149
112
  "mediaPoster": ""
150
113
  },
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
- }
167
- },
168
- {
169
- "id": "emojiBurst",
170
- "defaults": {
171
- "texts": "Let's go!"
172
- },
173
- "usesGlobalTransition": true,
174
- "transitionTiming": {
175
- "entryReadyProgress": 0.2,
176
- "holdProgress": 0.7
177
- }
114
+ "usesGlobalTransition": false
178
115
  },
179
116
  {
180
- "id": "bigNumber",
117
+ "id": "chapterTitle",
181
118
  "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"
119
+ "title": "A different perspective"
194
120
  },
195
- "usesGlobalTransition": true,
196
- "transitionTiming": {
197
- "entryReadyProgress": 0.2,
198
- "holdProgress": 0.7
199
- }
121
+ "usesGlobalTransition": false
200
122
  },
201
123
  {
202
- "id": "barChart",
124
+ "id": "editorialTimeline",
203
125
  "defaults": {
204
- "texts": "Revenue up 300%.",
205
- "bars": [
206
- {
207
- "label": "Q1",
208
- "value": 42
209
- },
126
+ "events": [
210
127
  {
211
- "label": "Q2",
212
- "value": 58
128
+ "label": "Observe"
213
129
  },
214
130
  {
215
- "label": "Q3",
216
- "value": 76
131
+ "label": "Understand"
217
132
  },
218
133
  {
219
- "label": "Q4",
220
- "value": 91
134
+ "label": "Act"
221
135
  }
222
136
  ],
223
137
  "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"
138
+ "mediaType": "video",
139
+ "mediaPoster": ""
470
140
  },
471
141
  "usesGlobalTransition": false
472
142
  },
473
143
  {
474
- "id": "incomingCall",
144
+ "id": "mobileMessage",
475
145
  "defaults": {
476
- "callerName": "Your brand",
477
- "subtitle": "is calling....",
478
- "declineLabel": "Decline",
479
- "acceptLabel": "Accept",
480
146
  "mediaUrl": "",
481
- "mediaKeyword": "",
482
- "mediaType": "auto",
147
+ "mediaType": "video",
483
148
  "mediaPoster": "",
484
- "mediaPosition": "center",
485
- "mediaTreatment": "cinematic"
149
+ "message": "Can we talk?",
150
+ "app": "Messages"
486
151
  },
487
152
  "usesGlobalTransition": false
488
153
  },
489
154
  {
490
- "id": "brandMessage",
155
+ "id": "comparison",
491
156
  "defaults": {
492
- "message": "We built this for you.",
157
+ "leftText": "More distractions",
158
+ "rightText": "More room to think",
493
159
  "mediaUrl": "",
494
- "mediaKeyword": "",
495
- "mediaType": "auto",
496
- "mediaPoster": "",
497
- "mediaPosition": "center",
498
- "mediaTreatment": "cinematic"
160
+ "mediaType": "video",
161
+ "mediaPoster": ""
499
162
  },
500
163
  "usesGlobalTransition": false
501
164
  },
502
165
  {
503
- "id": "promptInput",
166
+ "id": "quote",
504
167
  "defaults": {
505
- "promptText": "Make a launch video for our app",
506
168
  "mediaUrl": "",
507
- "mediaKeyword": "",
508
- "mediaType": "auto",
509
- "mediaPoster": "",
510
- "mediaPosition": "center",
511
- "mediaTreatment": "cinematic"
512
- },
513
- "usesGlobalTransition": false
514
- },
515
- {
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
169
+ "mediaType": "video",
170
+ "mediaPoster": ""
539
171
  },
540
172
  "usesGlobalTransition": false
541
173
  },
542
174
  {
543
- "id": "tripleStats",
175
+ "id": "keyFigure",
544
176
  "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
177
  "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"
178
+ "mediaType": "video",
179
+ "mediaPoster": ""
578
180
  },
579
181
  "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
182
  }
664
183
  ];
184
+ var GENERATED_BUILTIN_VIDEO_BACKDROP_IDS = ["cinemaMedia", "editorialTimeline", "mobileMessage", "comparison", "quote", "keyFigure"];
665
185
 
666
186
  // src/visual-system/catalog/builtin-player.ts
667
- function createPreloadableBuiltinTemplate(loader) {
187
+ function createPreloadableBuiltinTemplate(loader, mediaBackdrop) {
668
188
  let loaded;
669
189
  let failure;
670
190
  let pending;
@@ -687,7 +207,8 @@ function createPreloadableBuiltinTemplate(loader) {
687
207
  if (failure) throw failure;
688
208
  throw preload();
689
209
  };
690
- return { component, preload };
210
+ const template = { component, preload };
211
+ return mediaBackdrop ? markExternalVideoBackdropTemplate(template) : template;
691
212
  }
692
213
  function freezeValue(value) {
693
214
  if (value && typeof value === "object" && !Object.isFrozen(value)) {
@@ -699,7 +220,7 @@ function freezeValue(value) {
699
220
  var preloadableById = new Map(
700
221
  GENERATED_BUILTIN_PLAYER_TEMPLATES.map(({ id }) => [
701
222
  id,
702
- createPreloadableBuiltinTemplate(GENERATED_BUILTIN_TEMPLATE_LOADERS[id])
223
+ createPreloadableBuiltinTemplate(GENERATED_BUILTIN_TEMPLATE_LOADERS[id], GENERATED_BUILTIN_VIDEO_BACKDROP_IDS.includes(id))
703
224
  ])
704
225
  );
705
226
  var builtinPlayerTemplates = Object.freeze(
@@ -817,8 +338,6 @@ function GenerationCover({
817
338
  onStart
818
339
  }) {
819
340
  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
341
  return /* @__PURE__ */ jsxs(
823
342
  "div",
824
343
  {
@@ -832,9 +351,9 @@ function GenerationCover({
832
351
  placeItems: "center",
833
352
  padding: "12%",
834
353
  textAlign: "center",
835
- color: config?.style.brand.colors.foreground ?? "#ffffff",
836
- background: backgroundCss,
837
- fontFamily: fontStack(config?.style.brand.font)
354
+ color: "#fff",
355
+ background: "#000",
356
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Helvetica Neue", Roboto, Arial, sans-serif'
838
357
  },
839
358
  children: [
840
359
  /* @__PURE__ */ jsxs("div", { style: { position: "absolute", top: "28%", left: "10%", right: "10%" }, children: [
@@ -1013,6 +532,8 @@ function usePlaybackClock({
1013
532
  audioRef,
1014
533
  loopRef,
1015
534
  sceneIndexRef,
535
+ visualReadyRef,
536
+ posterBridgeKeysRef,
1016
537
  callbacksRef,
1017
538
  setCurrentTime,
1018
539
  setIsPlaying
@@ -1020,6 +541,16 @@ function usePlaybackClock({
1020
541
  useEffect(() => {
1021
542
  if (!isPlaying) return;
1022
543
  let stalled = false;
544
+ let onsetWaitSeconds = 0;
545
+ let clockWaitSeconds = 0;
546
+ let lastNarrationTime;
547
+ const failNarration = (error, state) => {
548
+ setIsPlaying(false);
549
+ try {
550
+ void Promise.resolve(callbacksRef.current.onError?.(error, state)).catch(() => void 0);
551
+ } catch {
552
+ }
553
+ };
1023
554
  const reportStall = (next) => {
1024
555
  if (stalled === next) return;
1025
556
  stalled = next;
@@ -1033,14 +564,46 @@ function usePlaybackClock({
1033
564
  const tick = (now) => {
1034
565
  const current = stateRef.current;
1035
566
  const config = current.config;
1036
- const delta = Math.max(0, (now - previous) / 1e3);
567
+ const elapsed = Math.max(0, (now - previous) / 1e3);
568
+ let narrationReady = true;
569
+ try {
570
+ narrationReady = callbacksRef.current.narrationReady?.() !== false;
571
+ } catch (cause) {
572
+ failNarration(cause instanceof Error ? cause : new Error("Narration readiness failed"), current);
573
+ return;
574
+ }
575
+ onsetWaitSeconds = narrationReady ? 0 : onsetWaitSeconds + elapsed;
576
+ if (onsetWaitSeconds >= 8) {
577
+ failNarration(new Error("Narration did not report audio onset within eight seconds"), current);
578
+ return;
579
+ }
580
+ const delta = narrationReady ? elapsed : 0;
1037
581
  previous = now;
1038
582
  const settled = current.status === "complete" || current.status === "error" || current.status === "aborted";
1039
583
  const looping = loopRef.current && settled;
1040
584
  let wrapped = false;
1041
585
  if (config?.scenes.length) {
1042
586
  const duration2 = getVideoDuration(config);
1043
- const raw = timeRef.current + delta;
587
+ const ranges = resolveVideoTimeline(config);
588
+ const cued = ranges[sceneIndexRef.current];
589
+ let narrationTime;
590
+ try {
591
+ narrationTime = cued ? callbacksRef.current.narrationTime?.(cued.scene) : void 0;
592
+ } catch (cause) {
593
+ failNarration(cause instanceof Error ? cause : new Error("Narration clock failed"), current);
594
+ return;
595
+ }
596
+ if (narrationTime !== void 0 && (!Number.isFinite(narrationTime) || narrationTime < 0)) {
597
+ failNarration(new Error("Narration clock must return finite nonnegative seconds"), current);
598
+ return;
599
+ }
600
+ clockWaitSeconds = narrationTime !== void 0 && narrationTime === lastNarrationTime && narrationReady && !stalled ? clockWaitSeconds + elapsed : 0;
601
+ lastNarrationTime = narrationTime;
602
+ if (clockWaitSeconds >= 8) {
603
+ failNarration(new Error("Narration audio clock did not advance within eight seconds"), current);
604
+ return;
605
+ }
606
+ const raw = narrationTime !== void 0 && cued ? cued.start - (cued.scene.narrationGroup?.offsetSeconds ?? 0) + narrationTime : timeRef.current + delta;
1044
607
  let nextTime;
1045
608
  if (looping && duration2 > 0 && raw >= duration2) {
1046
609
  nextTime = raw % duration2;
@@ -1048,6 +611,9 @@ function usePlaybackClock({
1048
611
  } else {
1049
612
  nextTime = Math.min(raw, duration2);
1050
613
  }
614
+ const target = ranges.find((range) => nextTime >= range.start && nextTime < range.end) ?? ranges.at(-1);
615
+ const waitingForVisual = Boolean(target && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(target.scene) && !posterBridgeKeysRef?.current.has(sceneReadinessKey(target.scene)));
616
+ if (waitingForVisual && target) nextTime = target.start;
1051
617
  if (nextTime !== timeRef.current) {
1052
618
  timeRef.current = nextTime;
1053
619
  setCurrentTime(nextTime);
@@ -1065,19 +631,20 @@ function usePlaybackClock({
1065
631
  if (remaining <= 0 && !looping) audio.pause();
1066
632
  }
1067
633
  const notify = callbacksRef.current.onSceneChange;
1068
- if (notify) {
634
+ if (notify && !waitingForVisual) {
1069
635
  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;
636
+ const ranges2 = resolveVideoTimeline(config);
637
+ const index = ranges2.findIndex((range) => nextTime >= range.start && nextTime < range.end);
638
+ const resolved = index === -1 && nextTime >= duration2 ? ranges2.length - 1 : index;
1073
639
  if (resolved !== -1 && resolved !== sceneIndexRef.current) {
1074
640
  sceneIndexRef.current = resolved;
1075
- notify(ranges[resolved].scene, resolved);
641
+ notify(ranges2[resolved].scene, resolved);
1076
642
  }
1077
643
  }
1078
644
  }
1079
645
  const duration = current.config ? getVideoDuration(current.config) : 0;
1080
- reportStall(!settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
646
+ const active = current.config ? resolveVideoTimeline(current.config).find((range) => timeRef.current >= range.start && timeRef.current < range.end) : void 0;
647
+ 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
648
  if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
1082
649
  else setIsPlaying(false);
1083
650
  };
@@ -1120,7 +687,10 @@ function VideoPlayerRuntime({
1120
687
  onComplete,
1121
688
  onError,
1122
689
  onSceneChange,
690
+ narrationReady,
691
+ narrationTime,
1123
692
  onFramePresented,
693
+ onMediaFramePresented,
1124
694
  onStallChange,
1125
695
  onStateChange
1126
696
  }) {
@@ -1146,6 +716,7 @@ function VideoPlayerRuntime({
1146
716
  const [state, setState] = useState(() => video ? savedVideoState(video) : createVideoState());
1147
717
  const [currentTime, setCurrentTime] = useState(0);
1148
718
  const [activeStream, setActiveStream] = useState(stream);
719
+ const [activeSavedVideo, setActiveSavedVideo] = useState(video);
1149
720
  const [replacementPending, setReplacementPending] = useState(false);
1150
721
  const [startRequested, setStartRequested] = useState(autoStartGeneration);
1151
722
  const [introPlaying, setIntroPlaying] = useState(autoStartGeneration);
@@ -1156,13 +727,35 @@ function VideoPlayerRuntime({
1156
727
  const timeRef = useRef(currentTime);
1157
728
  const audioRef = useRef(null);
1158
729
  const introStartedAtRef = useRef(autoStartGeneration ? performance.now() : null);
1159
- const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, onFramePresented, onStallChange, onStateChange });
730
+ const callbacksRef = useRef({ onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange });
1160
731
  const loopRef = useRef(loop);
1161
732
  const sceneIndexRef = useRef(-1);
733
+ const mediaFrameReportedRef = useRef(false);
734
+ const posterBridgeKeysRef = useRef(/* @__PURE__ */ new Set());
735
+ const visualReadyRef = useRef(void 0);
736
+ const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false, posterBridge = false) => {
737
+ if (posterBridge) {
738
+ posterBridgeKeysRef.current.add(key);
739
+ return;
740
+ }
741
+ if (error) {
742
+ setIsPlaying(false);
743
+ callbacksRef.current.onError?.(error, stateRef.current);
744
+ } else {
745
+ visualReadyRef.current = key;
746
+ if (actualVideoFrame && !mediaFrameReportedRef.current) {
747
+ mediaFrameReportedRef.current = true;
748
+ try {
749
+ void Promise.resolve(callbacksRef.current.onMediaFramePresented?.()).catch(() => void 0);
750
+ } catch {
751
+ }
752
+ }
753
+ }
754
+ }, []);
1162
755
  const playbackEndedRef = useRef(false);
1163
756
  stateRef.current = state;
1164
757
  timeRef.current = currentTime;
1165
- callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, onFramePresented, onStallChange, onStateChange };
758
+ callbacksRef.current = { onComplete, onPlaybackEnd, onError, onSceneChange, narrationReady, narrationTime, onFramePresented, onMediaFramePresented, onStallChange, onStateChange };
1166
759
  loopRef.current = loop;
1167
760
  const reportFramePresented = useMemo(() => {
1168
761
  let reported = false;
@@ -1191,9 +784,13 @@ function VideoPlayerRuntime({
1191
784
  output.default(audio, context);
1192
785
  }).catch(() => context.close());
1193
786
  };
1194
- if (stream !== activeStream) {
787
+ if (stream !== activeStream || video !== activeSavedVideo) {
1195
788
  const autoStartReplacement = Boolean(playbackMode && stream && shouldAutoPlay && !reducedMotion);
1196
789
  setActiveStream(stream);
790
+ setActiveSavedVideo(video);
791
+ mediaFrameReportedRef.current = false;
792
+ posterBridgeKeysRef.current.clear();
793
+ sceneIndexRef.current = -1;
1197
794
  setReplacementPending(stream != null);
1198
795
  setState(video ? savedVideoState(video) : createVideoState());
1199
796
  setCurrentTime(0);
@@ -1310,6 +907,8 @@ function VideoPlayerRuntime({
1310
907
  audioRef,
1311
908
  loopRef,
1312
909
  sceneIndexRef,
910
+ visualReadyRef,
911
+ posterBridgeKeysRef,
1313
912
  callbacksRef,
1314
913
  setCurrentTime,
1315
914
  setIsPlaying
@@ -1522,7 +1121,7 @@ function VideoPlayerRuntime({
1522
1121
  onStart: armPlayback
1523
1122
  }
1524
1123
  ),
1525
- !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx2(
1124
+ !generationCoverVisible && config?.scenes.length ? /* @__PURE__ */ jsx2(MountedReadinessContext.Provider, { value: reportVisualReady, children: /* @__PURE__ */ jsx2(
1526
1125
  VideoFrame,
1527
1126
  {
1528
1127
  kit,
@@ -1542,7 +1141,7 @@ function VideoPlayerRuntime({
1542
1141
  transformOrigin: "top left"
1543
1142
  }
1544
1143
  }
1545
- ) : null,
1144
+ ) }) : null,
1546
1145
  /* @__PURE__ */ jsx2(
1547
1146
  StartPosterButton,
1548
1147
  {
@@ -1748,17 +1347,55 @@ function createSceneTimeline(options) {
1748
1347
  };
1749
1348
  }
1750
1349
 
1350
+ // src/player/scene-readiness.ts
1351
+ var READY_AHEAD_SECONDS = 8;
1352
+ function canStartPreparedSequence(scenes, complete) {
1353
+ let seconds = 0;
1354
+ let count = 0;
1355
+ for (const scene of scenes) {
1356
+ if (!scene) break;
1357
+ count += 1;
1358
+ seconds += scene.timing.fixedDuration ?? 0;
1359
+ if (seconds >= READY_AHEAD_SECONDS) return true;
1360
+ }
1361
+ return complete && count > 0 && count === scenes.length;
1362
+ }
1363
+ function preparedSceneDuration(scene, spokenSeconds, metadata) {
1364
+ const timing = metadata?.timing;
1365
+ const authored = (timing?.revealSeconds ?? 0) + (timing?.holdSeconds ?? 0) + (timing?.exitSeconds ?? 0);
1366
+ const readable = Math.max(getSceneDurationBounds(scene, metadata).readable, authored);
1367
+ return Number.isFinite(spokenSeconds) && spokenSeconds > 0 ? Math.max(readable, spokenSeconds + 0.8) : Math.max(readable, getSceneDuration(scene, metadata));
1368
+ }
1369
+
1751
1370
  // src/player/use-narration.ts
1752
1371
  import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
1753
1372
  function useNarration(options) {
1754
1373
  const [speaking, setSpeaking] = useState2(false);
1755
1374
  const optionsRef = useRef2(options);
1756
1375
  optionsRef.current = options;
1376
+ const groupRef = useRef2(void 0);
1377
+ const clockRef = useRef2(void 0);
1378
+ const clockSceneRef = useRef2(void 0);
1379
+ const readyRef = useRef2(true);
1380
+ const getTime = useCallback((scene) => {
1381
+ const group = groupRef.current;
1382
+ if (optionsRef.current.enabled === false || (group ? scene.narrationGroup?.id !== group.id : scene.id !== clockSceneRef.current)) return void 0;
1383
+ if (!optionsRef.current.voice.getCurrentTime) return void 0;
1384
+ const time = currentRef.current ? optionsRef.current.voice.getCurrentTime() : void 0;
1385
+ if (currentRef.current && readyRef.current && time === void 0) return void 0;
1386
+ if (time !== void 0 && Number.isFinite(time)) clockRef.current = Math.min(group?.totalSeconds ?? Infinity, Math.max(group?.offsetSeconds ?? 0, time));
1387
+ return clockRef.current;
1388
+ }, []);
1389
+ const isReady = useCallback(() => optionsRef.current.enabled === false || readyRef.current || clockRef.current !== void 0 && clockRef.current >= (groupRef.current?.offsetSeconds ?? 0) + 0.04, []);
1757
1390
  const currentRef = useRef2(void 0);
1758
1391
  const spokenIndexRef = useRef2(void 0);
1759
1392
  const stop = useCallback(() => {
1393
+ readyRef.current = true;
1760
1394
  currentRef.current?.abort();
1761
1395
  currentRef.current = void 0;
1396
+ groupRef.current = void 0;
1397
+ clockRef.current = void 0;
1398
+ clockSceneRef.current = void 0;
1762
1399
  setSpeaking(false);
1763
1400
  }, []);
1764
1401
  const interrupt = useCallback(() => {
@@ -1770,37 +1407,103 @@ function useNarration(options) {
1770
1407
  const { voice, enabled = true } = optionsRef.current;
1771
1408
  if (!enabled) return;
1772
1409
  if (spokenIndexRef.current === index) return;
1410
+ const group = scene.narrationGroup;
1411
+ if (group && voice.supportsOffsets !== true) {
1412
+ stop();
1413
+ return;
1414
+ }
1415
+ if (group && currentRef.current && groupRef.current?.id === group.id && groupRef.current.text === group.text && spokenIndexRef.current === index - 1) {
1416
+ spokenIndexRef.current = index;
1417
+ return;
1418
+ }
1773
1419
  stop();
1774
1420
  spokenIndexRef.current = index;
1775
- const line = scene.narration?.trim();
1776
- if (!line) return;
1421
+ groupRef.current = group;
1422
+ clockRef.current = group?.offsetSeconds ?? 0;
1423
+ clockSceneRef.current = scene.id;
1424
+ const line = group?.text ?? scene.narration?.trim();
1425
+ if (!line) {
1426
+ clockRef.current = void 0;
1427
+ return;
1428
+ }
1777
1429
  const controller = new AbortController();
1778
1430
  currentRef.current = controller;
1431
+ readyRef.current = !group && !voice.getCurrentTime;
1779
1432
  setSpeaking(true);
1780
1433
  void (async () => {
1781
1434
  try {
1782
1435
  let started = false;
1783
1436
  await voice.speak(line, {
1784
1437
  signal: controller.signal,
1438
+ ...group ? { offsetSeconds: group.offsetSeconds } : {},
1785
1439
  onStart: (source) => {
1786
- if (started || controller.signal.aborted || currentRef.current !== controller || spokenIndexRef.current !== index || optionsRef.current.enabled === false) return;
1440
+ if (started || controller.signal.aborted || currentRef.current !== controller || !group && spokenIndexRef.current !== index || optionsRef.current.enabled === false) return;
1787
1441
  started = true;
1442
+ readyRef.current = true;
1788
1443
  try {
1789
1444
  void Promise.resolve(optionsRef.current.onSpeechStart?.(source)).catch(() => void 0);
1790
1445
  } catch {
1791
1446
  }
1792
1447
  }
1793
1448
  });
1449
+ if (currentRef.current === controller && !controller.signal.aborted) {
1450
+ clockRef.current = group && started ? group.totalSeconds : void 0;
1451
+ }
1794
1452
  } catch {
1453
+ if (currentRef.current === controller) {
1454
+ groupRef.current = void 0;
1455
+ clockRef.current = void 0;
1456
+ }
1795
1457
  } finally {
1796
1458
  if (currentRef.current === controller) {
1797
1459
  currentRef.current = void 0;
1460
+ readyRef.current = true;
1798
1461
  setSpeaking(false);
1799
1462
  }
1800
1463
  }
1801
1464
  })();
1802
1465
  }, [stop]);
1803
- return { onSceneChange, interrupt, speaking };
1466
+ return { onSceneChange, interrupt, speaking, isReady, getTime };
1467
+ }
1468
+
1469
+ // src/player/prepare-scene-media.ts
1470
+ async function prepareSceneMedia(variables, signal) {
1471
+ signal.throwIfAborted();
1472
+ const url = typeof variables.mediaUrl === "string" ? variables.mediaUrl.trim() : "";
1473
+ if (!url || variables.mediaType === "gradient") return "graphic";
1474
+ const video = variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(url);
1475
+ const poster = typeof variables.mediaPoster === "string" ? variables.mediaPoster.trim() : "";
1476
+ if (video && !poster) return "awaiting-video-frame";
1477
+ let posterFailed = false;
1478
+ await new Promise((resolve, reject) => {
1479
+ const image = new Image();
1480
+ let settled = false;
1481
+ const finish = (error) => {
1482
+ if (settled) return;
1483
+ settled = true;
1484
+ clearTimeout(timer);
1485
+ signal.removeEventListener("abort", abort);
1486
+ image.onload = null;
1487
+ image.onerror = null;
1488
+ if (error) {
1489
+ image.src = "";
1490
+ reject(error);
1491
+ } else resolve();
1492
+ };
1493
+ const abort = () => finish(signal.reason ?? new DOMException("Cancelled", "AbortError"));
1494
+ const timer = setTimeout(() => finish(new DOMException("Media preparation timed out", "TimeoutError")), 8e3);
1495
+ signal.addEventListener("abort", abort, { once: true });
1496
+ image.onload = () => {
1497
+ void (image.decode ? image.decode() : Promise.resolve()).then(() => finish(), () => finish(new Error("Could not prepare scene image")));
1498
+ };
1499
+ image.onerror = () => finish(new Error("Could not prepare scene image"));
1500
+ image.src = video ? poster : url;
1501
+ if (image.complete && image.naturalWidth > 0) image.onload(new Event("load"));
1502
+ }).catch((error) => {
1503
+ if (!video || signal.aborted) throw error;
1504
+ posterFailed = true;
1505
+ });
1506
+ return video ? posterFailed ? "awaiting-video-frame" : "poster-bridge" : "image";
1804
1507
  }
1805
1508
 
1806
1509
  // src/video-chat/voice.ts
@@ -1820,10 +1523,10 @@ async function measureSeconds(bytes) {
1820
1523
  try {
1821
1524
  sharedContext ??= new AudioContext();
1822
1525
  const decoded = await sharedContext.decodeAudioData(bytes.slice(0));
1823
- if (decoded.duration > 0) return decoded.duration;
1526
+ if (decoded.duration > 0) return { seconds: decoded.duration, measured: true };
1824
1527
  } catch {
1825
1528
  }
1826
- return bytes.byteLength * 8 / FALLBACK_BITS_PER_SECOND;
1529
+ return { seconds: bytes.byteLength * 8 / FALLBACK_BITS_PER_SECOND, measured: false };
1827
1530
  }
1828
1531
  function createVideoChatVoice(options = {}) {
1829
1532
  const endpoint = options.endpoint ?? "/api/video-chat";
@@ -1898,7 +1601,7 @@ function createVideoChatVoice(options = {}) {
1898
1601
  createdSrc = URL.createObjectURL(new Blob([bytes], {
1899
1602
  type: response.headers.get("content-type") || "audio/mpeg"
1900
1603
  }));
1901
- return { source: "generated", src: createdSrc, seconds };
1604
+ return { source: "generated", src: createdSrc, ...seconds };
1902
1605
  }, SPEECH_PREPARATION_TIMEOUT_MS, controller.signal);
1903
1606
  }
1904
1607
  } catch (cause) {
@@ -1944,9 +1647,11 @@ function createVideoChatVoice(options = {}) {
1944
1647
  browserFinish?.();
1945
1648
  };
1946
1649
  return {
1650
+ supportsOffsets: true,
1651
+ getCurrentTime: () => sounding?.currentTime,
1947
1652
  async prepare(text, preparation = {}) {
1948
1653
  const line = await load(text, preparation.signal);
1949
- return { seconds: line.seconds };
1654
+ return { seconds: line.seconds, ...line.source === "generated" && line.measured === true ? { supportsOffsets: true } : {} };
1950
1655
  },
1951
1656
  pause() {
1952
1657
  held = true;
@@ -1955,7 +1660,7 @@ function createVideoChatVoice(options = {}) {
1955
1660
  },
1956
1661
  resume() {
1957
1662
  held = false;
1958
- if (sounding && !silent) void sounding.play().catch(() => playbackFailure?.());
1663
+ if (sounding) void sounding.play().catch(() => playbackFailure?.());
1959
1664
  if (!silent) globalThis.speechSynthesis?.resume();
1960
1665
  },
1961
1666
  setMuted(muted) {
@@ -1963,7 +1668,7 @@ function createVideoChatVoice(options = {}) {
1963
1668
  if (sounding) sounding.muted = muted;
1964
1669
  if (muted) stopBrowser();
1965
1670
  },
1966
- async speak(text, { signal, onStart }) {
1671
+ async speak(text, { signal, onStart, offsetSeconds }) {
1967
1672
  let started = false;
1968
1673
  const notifyStart = (source) => {
1969
1674
  if (started || disposed || signal.aborted || silent || held) return;
@@ -1975,17 +1680,21 @@ function createVideoChatVoice(options = {}) {
1975
1680
  };
1976
1681
  const line = await load(text, signal);
1977
1682
  if (disposed || signal.aborted || silent) return;
1683
+ 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
1684
  if (line.source === "browser") {
1979
1685
  const synthesis = globalThis.speechSynthesis;
1980
- if (!synthesis || typeof SpeechSynthesisUtterance === "undefined") return;
1686
+ if (!synthesis || typeof SpeechSynthesisUtterance === "undefined") throw new Error("Browser voice is unavailable");
1981
1687
  const utterance = new SpeechSynthesisUtterance(text);
1982
1688
  utterance.rate = 1;
1689
+ let unavailable = false;
1983
1690
  await new Promise((resolve) => {
1984
1691
  let finished = false;
1692
+ let onsetRemainingMs = 2e3;
1985
1693
  const finish = () => {
1986
1694
  if (finished) return;
1987
1695
  finished = true;
1988
1696
  clearWatchdog();
1697
+ clearInterval(onsetTimer);
1989
1698
  signal.removeEventListener("abort", stop);
1990
1699
  speechStops.delete(stop);
1991
1700
  utterance.onstart = null;
@@ -1995,38 +1704,54 @@ function createVideoChatVoice(options = {}) {
1995
1704
  resolve();
1996
1705
  };
1997
1706
  const stop = () => {
1998
- synthesis.cancel();
1999
1707
  finish();
1708
+ synthesis.cancel();
2000
1709
  };
2001
1710
  const clearWatchdog = watchSpeech(estimatedBrowserSeconds(text), stop);
2002
1711
  speechStops.add(stop);
2003
1712
  browserFinish = finish;
1713
+ const fail = () => {
1714
+ unavailable = true;
1715
+ stop();
1716
+ };
1717
+ const onsetTimer = setInterval(() => {
1718
+ if (held || finished) return;
1719
+ onsetRemainingMs -= 250;
1720
+ if (onsetRemainingMs <= 0) fail();
1721
+ }, 250);
2004
1722
  utterance.onstart = () => {
2005
- if (!finished) notifyStart("browser");
1723
+ if (finished) return;
1724
+ clearInterval(onsetTimer);
1725
+ notifyStart("browser");
2006
1726
  };
2007
1727
  utterance.onend = finish;
2008
- utterance.onerror = finish;
1728
+ utterance.onerror = fail;
2009
1729
  signal.addEventListener("abort", stop, { once: true });
2010
1730
  try {
2011
1731
  synthesis.speak(utterance);
2012
1732
  if (held) synthesis.pause();
2013
1733
  } catch {
2014
- finish();
1734
+ fail();
2015
1735
  }
2016
1736
  });
1737
+ if (unavailable) throw new Error("Browser voice is unavailable");
2017
1738
  return;
2018
1739
  }
2019
1740
  let playbackFailed = false;
2020
1741
  try {
2021
1742
  const element = new Audio(line.src);
2022
1743
  element.muted = silent;
1744
+ if (offsetSeconds !== void 0) element.currentTime = offsetSeconds;
2023
1745
  sounding = element;
2024
1746
  await new Promise((resolve) => {
2025
1747
  let finished = false;
1748
+ let onsetTimer;
1749
+ const initialTime = offsetSeconds ?? 0;
2026
1750
  const finish = () => {
2027
1751
  if (finished) return;
2028
1752
  finished = true;
2029
1753
  clearWatchdog();
1754
+ clearTimeout(onsetTimer);
2030
1755
  signal.removeEventListener("abort", stop);
2031
1756
  speechStops.delete(stop);
2032
1757
  element.onplaying = null;
@@ -2049,9 +1774,13 @@ function createVideoChatVoice(options = {}) {
2049
1774
  const clearWatchdog = watchSpeech(Math.max(line.seconds, estimatedBrowserSeconds(text)), fail);
2050
1775
  speechStops.add(stop);
2051
1776
  playbackFailure = fail;
2052
- element.onplaying = () => {
2053
- if (!finished) notifyStart("generated");
1777
+ const observeClock = () => {
1778
+ clearTimeout(onsetTimer);
1779
+ if (finished || started) return;
1780
+ if (!held && element.currentTime >= initialTime + 0.04) notifyStart("generated");
1781
+ if (!started) onsetTimer = setTimeout(observeClock, 16);
2054
1782
  };
1783
+ element.onplaying = observeClock;
2055
1784
  element.onended = finish;
2056
1785
  element.onerror = fail;
2057
1786
  signal.addEventListener("abort", stop, { once: true });
@@ -2064,6 +1793,7 @@ function createVideoChatVoice(options = {}) {
2064
1793
  } catch {
2065
1794
  playbackFailed = true;
2066
1795
  }
1796
+ if (playbackFailed && offsetSeconds !== void 0) throw new Error("Grouped narration playback failed");
2067
1797
  if (playbackFailed && !disposed && !signal.aborted && !silent) {
2068
1798
  notifyFallback();
2069
1799
  URL.revokeObjectURL(line.src);
@@ -2324,7 +2054,11 @@ async function responseError(response) {
2324
2054
  });
2325
2055
  }
2326
2056
  function pacedScene(scene, spokenSeconds, templates) {
2327
- const held = spokenSeconds && spokenSeconds > 0 ? spokenSeconds + 0.8 : getSceneDuration(scene, templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId));
2057
+ const held = preparedSceneDuration(
2058
+ scene,
2059
+ spokenSeconds,
2060
+ templates?.getTemplateMetadata(scene.templateId) ?? getBuiltinTemplateMetadata(scene.templateId)
2061
+ );
2328
2062
  const {
2329
2063
  startTime: _startTime,
2330
2064
  endTime: _endTime,
@@ -2334,21 +2068,6 @@ function pacedScene(scene, spokenSeconds, templates) {
2334
2068
  } = scene.timing ?? {};
2335
2069
  return { ...scene, timing: { ...timing, fixedDuration: held } };
2336
2070
  }
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
2071
  function useVideoChat(options = {}) {
2353
2072
  return useVideoChatSession(options).chat;
2354
2073
  }
@@ -2372,7 +2091,17 @@ function useVideoChatSession(options = {}) {
2372
2091
  const unavailableVoiceLines = useRef3(/* @__PURE__ */ new Set());
2373
2092
  const speechStartRef = useRef3(() => void 0);
2374
2093
  const narration = useNarration({ onSpeechStart: (source) => speechStartRef.current(source), voice: {
2375
- speak: (text, options2) => unavailableVoiceLines.current.has(text) ? void 0 : voice.speak(text, options2)
2094
+ supportsOffsets: voice.supportsOffsets,
2095
+ ...voice.getCurrentTime ? { getCurrentTime: () => voice.getCurrentTime() } : {},
2096
+ speak: async (text, options2) => {
2097
+ if (unavailableVoiceLines.current.has(text)) return;
2098
+ try {
2099
+ await voice.speak(text, options2);
2100
+ } catch (error) {
2101
+ if (!options2.signal.aborted) voiceWarningRef.current("Voice playback is unavailable. Continuing with subtitles.");
2102
+ throw error;
2103
+ }
2104
+ }
2376
2105
  } });
2377
2106
  const narrationRef = useRef3(narration);
2378
2107
  narrationRef.current = narration;
@@ -2511,9 +2240,7 @@ function useVideoChatSession(options = {}) {
2511
2240
  throw new VideoError("timeoutMs must be positive", { code: "invalid_option" });
2512
2241
  }
2513
2242
  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;
2243
+ const mode = "cinematic";
2517
2244
  const orientation = currentOptions.orientation ?? "landscape";
2518
2245
  const id = (currentOptions.createTurnId ?? defaultTurnId)();
2519
2246
  const conversation = conversationFor(stateRef.current.turns);
@@ -2524,7 +2251,7 @@ function useVideoChatSession(options = {}) {
2524
2251
  prompt,
2525
2252
  completed: false,
2526
2253
  orientation,
2527
- fixedOrientation: mode === "full",
2254
+ fixedOrientation: true,
2528
2255
  suggestions: [],
2529
2256
  ...openingMedia ? { openingMedia } : {}
2530
2257
  };
@@ -2547,11 +2274,23 @@ function useVideoChatSession(options = {}) {
2547
2274
  const warn = (message) => {
2548
2275
  if (isCurrent()) dispatch({ type: "warning", id, message });
2549
2276
  };
2550
- voiceWarningRef.current = () => warn("Using browser voice for this response.");
2277
+ voiceWarningRef.current = (message = "Using browser voice for this response.") => warn(message);
2551
2278
  const flush = () => {
2552
2279
  if (!isCurrent()) return;
2553
2280
  let available = appended;
2554
- while (ready[available]) available += 1;
2281
+ while (ready[available]) {
2282
+ const group = ready[available].narrationGroup;
2283
+ if (!group) {
2284
+ available++;
2285
+ continue;
2286
+ }
2287
+ let end = available;
2288
+ while (ready[end]?.narrationGroup?.id === group.id) end++;
2289
+ const last = ready[end - 1].narrationGroup;
2290
+ if (Math.abs(last.offsetSeconds + last.durationSeconds - group.totalSeconds) > 0.02) break;
2291
+ validateNarrationGroups(ready.slice(available, end));
2292
+ available = end;
2293
+ }
2555
2294
  if (available === appended && timeline) {
2556
2295
  if (planDone && !timelineCompleted) {
2557
2296
  timelineCompleted = true;
@@ -2561,18 +2300,18 @@ function useVideoChatSession(options = {}) {
2561
2300
  return;
2562
2301
  }
2563
2302
  if (!timeline) {
2564
- if (!style || openingActive || heldRef.current || available === appended) return;
2303
+ if (!style || openingActive || heldRef.current || available === appended || !canStartPreparedSequence(ready.slice(0, available), planDone)) return;
2565
2304
  timeline = createSceneTimeline({ style, orientation });
2566
2305
  timelineRef.current = timeline;
2567
2306
  openingController.abort(new DOMException("Opening replaced by response", "AbortError"));
2568
2307
  if (openingRef.current === openingController) openingRef.current = void 0;
2569
2308
  dispatch({ type: "player", id, stream: timeline.stream });
2570
2309
  }
2571
- while (ready[appended]) timeline.add(ready[appended++]);
2310
+ while (appended < available) timeline.add(ready[appended++]);
2572
2311
  dispatch({
2573
2312
  type: "partial",
2574
2313
  id,
2575
- video: { schemaVersion: "0.1", orientation, scenes: ready.slice(0, appended), style }
2314
+ video: { schemaVersion: VIDEO_SCHEMA_VERSION, orientation, scenes: ready.slice(0, appended), style }
2576
2315
  });
2577
2316
  if (planDone && !timelineCompleted) {
2578
2317
  timelineCompleted = true;
@@ -2634,7 +2373,6 @@ function useVideoChatSession(options = {}) {
2634
2373
  mode,
2635
2374
  orientation,
2636
2375
  conversation,
2637
- ...currentOptions.brand ? { brand: currentOptions.brand } : {},
2638
2376
  ...currentOptions.style ? { style: currentOptions.style } : {}
2639
2377
  })
2640
2378
  }, controller.signal);
@@ -2649,7 +2387,7 @@ function useVideoChatSession(options = {}) {
2649
2387
  let terminalError;
2650
2388
  try {
2651
2389
  for await (const event of decodeVideoSse(response.body)) {
2652
- if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: "0.1", orientation, scenes: [], style }, lines: [] };
2390
+ if (!isCurrent() || currentAttempt !== attempt) return { video: { schemaVersion: VIDEO_SCHEMA_VERSION, orientation, scenes: [], style }, lines: [] };
2653
2391
  if (event.type === "response.start") style = event.data.style;
2654
2392
  if (event.type === "response.warning" || event.type === "response.error" && !event.data.terminal) {
2655
2393
  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 +2421,12 @@ function useVideoChatSession(options = {}) {
2683
2421
  const position = event.data.position;
2684
2422
  const plannedScene = event.data.scene;
2685
2423
  planned[position] = plannedScene;
2686
- received[position] = prepareVisualScene(plannedScene, mode);
2687
- if (!currentOptions.templates?.getTemplate(plannedScene.templateId)) {
2688
- preloadBuiltinTemplate(plannedScene.templateId);
2689
- }
2424
+ received[position] = plannedScene;
2425
+ const rendererReady = currentOptions.templates?.getTemplate(plannedScene.templateId) ? Promise.resolve() : Promise.resolve(preloadBuiltinTemplate(plannedScene.templateId));
2426
+ const visualPreparation = Promise.all([rendererReady, prepareSceneMedia(plannedScene.variables, controller.signal)]).then(() => void 0, () => {
2427
+ throw new VideoError("Scene could not prepare its visual", { code: "media_not_ready" });
2428
+ });
2429
+ void visualPreparation.catch(() => void 0);
2690
2430
  warmSceneMedia(plannedScene.variables);
2691
2431
  const narrated = narrating.then(async () => {
2692
2432
  const supplied = plannedScene.narration?.trim();
@@ -2713,18 +2453,25 @@ function useVideoChatSession(options = {}) {
2713
2453
  narrating = narrated.catch(() => "");
2714
2454
  pending.push(narrated.then(async (line) => {
2715
2455
  if (!isCurrent() || currentAttempt !== attempt) return;
2716
- const visual = prepareVisualScene(plannedScene, mode);
2456
+ const visual = plannedScene;
2717
2457
  const withNarration = line ? { ...visual, narration: line } : visual;
2718
- const spoken = line ? await prepareSpeech(line, controller.signal).catch((cause) => {
2458
+ const spoken = line ? await prepareSpeech(plannedScene.narrationGroup?.text ?? line, controller.signal).catch((cause) => {
2719
2459
  if (controller.signal.aborted) throw cause;
2720
2460
  warn("Some narration is unavailable; the response will continue.");
2721
2461
  return void 0;
2722
2462
  }) : void 0;
2723
2463
  if (!isCurrent() || currentAttempt !== attempt) return;
2724
- ready[position] = pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2464
+ await visualPreparation;
2465
+ const group = plannedScene.narrationGroup;
2466
+ 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" });
2467
+ ready[position] = group ? withNarration : pacedScene(withNarration, spoken?.seconds, currentOptions.templates);
2725
2468
  flush();
2726
- }).catch(() => {
2469
+ }).catch((cause) => {
2727
2470
  if (!isCurrent() || currentAttempt !== attempt) return;
2471
+ if (cause instanceof VideoError && ["media_not_ready", "narration_group_invalid"].includes(cause.code)) {
2472
+ terminalError = cause;
2473
+ return;
2474
+ }
2728
2475
  ready[position] = { ...received[position], timing: { fixedDuration: 5 } };
2729
2476
  warn("Some parts were simplified so the response could continue.");
2730
2477
  flush();
@@ -2735,6 +2482,8 @@ function useVideoChatSession(options = {}) {
2735
2482
  terminalError = errorFrom(cause);
2736
2483
  }
2737
2484
  await Promise.all(pending);
2485
+ if (terminalError && ["media_not_ready", "narration_group_invalid"].includes(terminalError.code)) throw terminalError;
2486
+ validateNarrationGroups(ready.filter((scene) => Boolean(scene)));
2738
2487
  if (terminalError && ready.some(Boolean) && style) {
2739
2488
  warn("The response was interrupted; completed scenes are still available.");
2740
2489
  } else if (terminalError) throw terminalError;
@@ -2743,7 +2492,7 @@ function useVideoChatSession(options = {}) {
2743
2492
  }
2744
2493
  return {
2745
2494
  video: {
2746
- schemaVersion: "0.1",
2495
+ schemaVersion: VIDEO_SCHEMA_VERSION,
2747
2496
  orientation,
2748
2497
  scenes: ready.filter((entry) => entry != null),
2749
2498
  style
@@ -2756,7 +2505,7 @@ function useVideoChatSession(options = {}) {
2756
2505
  try {
2757
2506
  response = await untilAborted(runAttempt(attempt), controller.signal);
2758
2507
  } catch (cause) {
2759
- if (controller.signal.aborted || timeline || spokenHook) throw cause;
2508
+ 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
2509
  attempt += 1;
2761
2510
  ready = [];
2762
2511
  received = [];
@@ -2792,9 +2541,9 @@ function useVideoChatSession(options = {}) {
2792
2541
  return void 0;
2793
2542
  }
2794
2543
  terminal = true;
2795
- const recovered = received.flatMap((scene, index) => scene ? [ready[index] ?? { ...scene, timing: { fixedDuration: 5 } }] : []);
2544
+ 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
2545
  if (recovered.length > 0) {
2797
- style ??= { brand: resolveVideoBrand(currentOptions.brand), density: "normal", motion: "normal", defaultBackgroundEffect: "static", defaultTextArchetype: "subtle", defaultTransition: "crossfade" };
2546
+ style ??= { density: "normal", motion: "normal", defaultBackgroundEffect: "static", defaultTextArchetype: "subtle", defaultTransition: "crossfade" };
2798
2547
  openingController.abort(new DOMException("Continuing completed response", "AbortError"));
2799
2548
  if (!timeline) {
2800
2549
  timeline = createSceneTimeline({ style, orientation });
@@ -2803,7 +2552,7 @@ function useVideoChatSession(options = {}) {
2803
2552
  for (const scene of recovered.slice(appended)) timeline.add(scene);
2804
2553
  timeline.complete();
2805
2554
  if (timelineRef.current === timeline) timelineRef.current = void 0;
2806
- const video = { schemaVersion: "0.1", orientation, style, scenes: recovered };
2555
+ const video = { schemaVersion: VIDEO_SCHEMA_VERSION, orientation, style, scenes: recovered };
2807
2556
  dispatch({ type: "warning", id, message: "The response was interrupted; completed scenes are still available." });
2808
2557
  dispatch({ type: "complete", id, video, suggestions: [] });
2809
2558
  return video;
@@ -2831,9 +2580,18 @@ function useVideoChatSession(options = {}) {
2831
2580
  dispatch({ type: "resume" });
2832
2581
  flushRef.current?.();
2833
2582
  }, []);
2583
+ const prepareSavedGroups = useCallback2(async (video) => {
2584
+ validateNarrationGroups(video.scenes);
2585
+ const groups = new Map(video.scenes.flatMap((scene) => scene.narrationGroup ? [[scene.narrationGroup.id, scene.narrationGroup]] : []));
2586
+ for (const group of groups.values()) {
2587
+ const prepared = await withDeadline((signal) => voiceRef.current.prepare(group.text, { signal }), 3e3);
2588
+ 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" });
2589
+ }
2590
+ }, []);
2834
2591
  const replay = useCallback2(() => {
2835
2592
  const turn = stateRef.current.turns.find((entry) => entry.id === stateRef.current.shownTurnId);
2836
2593
  if (!turn?.completed || !turn.video) return;
2594
+ runRef.current += 1;
2837
2595
  endTiming();
2838
2596
  if (inFlightRef.current) {
2839
2597
  runRef.current += 1;
@@ -2847,11 +2605,20 @@ function useVideoChatSession(options = {}) {
2847
2605
  narrationRef.current.interrupt();
2848
2606
  heldRef.current = false;
2849
2607
  voiceRef.current.resume();
2850
- dispatch({ type: "replay" });
2851
- }, [endTiming]);
2608
+ if (turn.video.scenes.some((scene) => scene.narrationGroup)) {
2609
+ const selection = ++runRef.current;
2610
+ dispatch({ type: "pause" });
2611
+ void prepareSavedGroups(turn.video).then(() => {
2612
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "replay" });
2613
+ }).catch((cause) => {
2614
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "error", id: turn.id, error: errorFrom(cause) });
2615
+ });
2616
+ } else dispatch({ type: "replay" });
2617
+ }, [endTiming, prepareSavedGroups]);
2852
2618
  const selectTurn = useCallback2((id) => {
2853
2619
  const turn = stateRef.current.turns.find((entry) => entry.id === id);
2854
2620
  if (!turn?.completed || !turn.video) return;
2621
+ runRef.current += 1;
2855
2622
  endTiming();
2856
2623
  if (inFlightRef.current) {
2857
2624
  runRef.current += 1;
@@ -2865,8 +2632,16 @@ function useVideoChatSession(options = {}) {
2865
2632
  narrationRef.current.interrupt();
2866
2633
  heldRef.current = false;
2867
2634
  voiceRef.current.resume();
2868
- dispatch({ type: "select", id });
2869
- }, [endTiming]);
2635
+ if (turn.video.scenes.some((scene) => scene.narrationGroup)) {
2636
+ const selection = ++runRef.current;
2637
+ dispatch({ type: "pause" });
2638
+ void prepareSavedGroups(turn.video).then(() => {
2639
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "select", id });
2640
+ }).catch((cause) => {
2641
+ if (mountedRef.current && runRef.current === selection) dispatch({ type: "error", id, error: errorFrom(cause) });
2642
+ });
2643
+ } else dispatch({ type: "select", id });
2644
+ }, [endTiming, prepareSavedGroups]);
2870
2645
  const reset = useCallback2(() => {
2871
2646
  cancel("Session reset");
2872
2647
  heldRef.current = false;
@@ -2882,7 +2657,7 @@ function useVideoChatSession(options = {}) {
2882
2657
  const setMuted = useCallback2((muted) => dispatch({ type: "mute", value: muted }), []);
2883
2658
  const currentTurn = state.turns.at(-1);
2884
2659
  const shownTurn = state.turns.find((turn) => turn.id === state.shownTurnId) ?? currentTurn;
2885
- const availableModes = state.capabilities?.modes ?? ["templates"];
2660
+ const availableModes = state.capabilities?.modes ?? ["cinematic"];
2886
2661
  const suggestions = shownTurn?.suggestions ?? [];
2887
2662
  const fullTranscript = shownTurn ? transcriptFor(shownTurn) : [];
2888
2663
  const transcript = shownTurn && shownTurn === currentTurn && state.playback?.kind !== "video" ? fullTranscript.slice(0, (shownTurn.opening ? 1 : 0) + state.spokenUpTo + 1) : fullTranscript;
@@ -2893,6 +2668,8 @@ function useVideoChatSession(options = {}) {
2893
2668
  autoPlay: true,
2894
2669
  paused: state.status === "paused",
2895
2670
  controls: false,
2671
+ narrationReady: narration.isReady,
2672
+ narrationTime: narration.getTime,
2896
2673
  orientation: shownTurn?.fixedOrientation ? shownTurn.orientation : "auto",
2897
2674
  onFramePresented: () => {
2898
2675
  const timing = firstFrameRef.current;
@@ -2902,8 +2679,22 @@ function useVideoChatSession(options = {}) {
2902
2679
  observe(() => optionsRef.current.onFirstFrame?.({ turnId: timing.turnId, mode: timing.mode, timeToFirstFrameMs: elapsedMs }));
2903
2680
  reportMetric({ type: "first-frame", turnId: timing.turnId, mode: timing.mode, elapsedMs });
2904
2681
  },
2682
+ onMediaFramePresented: () => {
2683
+ const timing = firstFrameRef.current;
2684
+ if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream" || !timing?.active || timing.mediaReported) return;
2685
+ timing.mediaReported = true;
2686
+ reportMetric({
2687
+ type: "first-media-frame",
2688
+ turnId: timing.turnId,
2689
+ mode: timing.mode,
2690
+ elapsedMs: Math.max(0, Math.round(monotonicNow() - timing.startedAt))
2691
+ });
2692
+ },
2905
2693
  onStallChange: (stalled) => {
2906
- if (stateRef.current.playerKey !== playbackKey || state.playback?.kind !== "stream") return;
2694
+ if (stateRef.current.playerKey !== playbackKey) return;
2695
+ if (stalled) voiceRef.current.pause();
2696
+ else if (!heldRef.current) voiceRef.current.resume();
2697
+ if (state.playback?.kind !== "stream") return;
2907
2698
  const timing = firstFrameRef.current;
2908
2699
  if (!timing?.active || !timing.reported) return;
2909
2700
  if (!stalled) finishStall();
@@ -2921,6 +2712,7 @@ function useVideoChatSession(options = {}) {
2921
2712
  },
2922
2713
  onError: (cause) => {
2923
2714
  if (stateRef.current.playerKey !== playbackKey) return;
2715
+ narrationRef.current.interrupt();
2924
2716
  const id = stateRef.current.turns.at(-1)?.id;
2925
2717
  if (id) dispatch({ type: "error", id, error: errorFrom(cause) });
2926
2718
  }
@@ -2956,68 +2748,7 @@ function useVideoChatSession(options = {}) {
2956
2748
  }
2957
2749
 
2958
2750
  // 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;
2751
+ import { useCallback as useCallback6, useEffect as useEffect9, useId, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef8, useState as useState7 } from "react";
3021
2752
 
3022
2753
  // src/video-chat/icons.tsx
3023
2754
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
@@ -3129,7 +2860,7 @@ function useFocusTrap(active, surface) {
3129
2860
  // src/video-chat/suggestion-cards.tsx
3130
2861
  import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
3131
2862
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3132
- function Frame({ media, poster, playing }) {
2863
+ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = false }) {
3133
2864
  const video = useRef4(null);
3134
2865
  const [playingUrl, setPlayingUrl] = useState3(null);
3135
2866
  useEffect6(() => {
@@ -3139,7 +2870,12 @@ function Frame({ media, poster, playing }) {
3139
2870
  else element.pause();
3140
2871
  }, [playing]);
3141
2872
  if (!media) return null;
3142
- if (media.type === "image") return /* @__PURE__ */ jsx4("img", { className: "frame-media", src: media.url, alt: "" });
2873
+ const ready = () => {
2874
+ setPlayingUrl(media.url);
2875
+ onReady?.();
2876
+ };
2877
+ const appearance = revealWhenReady ? { opacity: playingUrl === media.url ? 1 : 0, transition: "opacity 200ms ease" } : void 0;
2878
+ if (media.type === "image") return /* @__PURE__ */ jsx4("img", { className: "frame-media", src: media.url, alt: "", style: appearance, onLoad: ready, onError });
3143
2879
  return /* @__PURE__ */ jsxs4(Fragment2, { children: [
3144
2880
  /* @__PURE__ */ jsx4(
3145
2881
  "video",
@@ -3148,8 +2884,12 @@ function Frame({ media, poster, playing }) {
3148
2884
  className: "frame-media",
3149
2885
  src: media.url,
3150
2886
  poster: poster ? media.posterUrl : void 0,
3151
- onPlaying: () => setPlayingUrl(media.url),
3152
- onError: () => setPlayingUrl(null),
2887
+ style: appearance,
2888
+ onPlaying: ready,
2889
+ onError: () => {
2890
+ setPlayingUrl(null);
2891
+ onError?.();
2892
+ },
3153
2893
  autoPlay: playing !== false,
3154
2894
  muted: true,
3155
2895
  loop: true,
@@ -3158,7 +2898,7 @@ function Frame({ media, poster, playing }) {
3158
2898
  "aria-hidden": "true"
3159
2899
  }
3160
2900
  ),
3161
- poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx4("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "" })
2901
+ poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx4("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
3162
2902
  ] });
3163
2903
  }
3164
2904
  function SuggestionCards({ suggestions, label, onAsk }) {
@@ -3231,8 +2971,41 @@ function Welcome({ data, onAsk, title }) {
3231
2971
  ] });
3232
2972
  }
3233
2973
 
2974
+ // src/video-chat/opening-chapter.tsx
2975
+ import { useLayoutEffect, useRef as useRef5, useState as useState4 } from "react";
2976
+ import { jsx as jsx6 } from "react/jsx-runtime";
2977
+ function OpeningChapter({ title }) {
2978
+ const root = useRef5(null);
2979
+ const [size, setSize] = useState4({ width: 1080, height: 1080 });
2980
+ useLayoutEffect(() => {
2981
+ const element = root.current;
2982
+ if (!element) return;
2983
+ const measure = () => {
2984
+ const { width, height } = element.getBoundingClientRect();
2985
+ if (width > 0 && height > 0) setSize({ width, height });
2986
+ };
2987
+ measure();
2988
+ if (typeof ResizeObserver === "undefined") return;
2989
+ const observer = new ResizeObserver(measure);
2990
+ observer.observe(element);
2991
+ return () => observer.disconnect();
2992
+ }, []);
2993
+ return /* @__PURE__ */ jsx6("div", { ref: root, "data-opening-chapter": true, className: "opening-chapter", children: /* @__PURE__ */ jsx6(
2994
+ TitleSceneTemplate,
2995
+ {
2996
+ variables: { title },
2997
+ style: {},
2998
+ width: size.width,
2999
+ height: size.height,
3000
+ progress: 0.3,
3001
+ beatIntensity: 0,
3002
+ safeZone: { top: 0, right: 0, bottom: 0, left: 0 }
3003
+ }
3004
+ ) });
3005
+ }
3006
+
3234
3007
  // src/video-chat/use-voice-input.ts
3235
- import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef5, useState as useState4 } from "react";
3008
+ import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef6, useState as useState5 } from "react";
3236
3009
  function recognitionConstructor() {
3237
3010
  if (typeof window === "undefined") return void 0;
3238
3011
  const holder = window;
@@ -3302,14 +3075,14 @@ async function recordAndTranscribe(signal, ready, captured, options) {
3302
3075
  return clip ? transcribeRecording(clip, signal, options) : "";
3303
3076
  }
3304
3077
  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);
3078
+ const [supported, setSupported] = useState5(false);
3079
+ const [listening, setListening] = useState5(false);
3080
+ const [error, setError] = useState5();
3081
+ const [thinking, setThinking] = useState5(false);
3082
+ const recognitionRef = useRef6(void 0);
3083
+ const useRecorderRef = useRef6(false);
3084
+ const operationRef = useRef6(void 0);
3085
+ const handlerRef = useRef6(onTranscript);
3313
3086
  handlerRef.current = onTranscript;
3314
3087
  useEffect7(() => {
3315
3088
  setSupported(supportsVoiceInput(transcriptionAvailable));
@@ -3436,13 +3209,13 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
3436
3209
  }
3437
3210
 
3438
3211
  // src/video-chat/use-immersive-controls.ts
3439
- import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef6, useState as useState5 } from "react";
3212
+ import { useCallback as useCallback5, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
3440
3213
  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);
3214
+ const [visible, setVisible] = useState6(true);
3215
+ const hovered = useRef7(false);
3216
+ const focused = useRef7(false);
3217
+ const previousCaptions = useRef7(false);
3218
+ const timer = useRef7(null);
3446
3219
  const clearTimer = useCallback5(() => {
3447
3220
  if (timer.current !== null) clearTimeout(timer.current);
3448
3221
  timer.current = null;
@@ -3493,16 +3266,16 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
3493
3266
  }
3494
3267
 
3495
3268
  // src/video-chat/logo.tsx
3496
- import { jsx as jsx6 } from "react/jsx-runtime";
3269
+ import { jsx as jsx7 } from "react/jsx-runtime";
3497
3270
  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 });
3271
+ 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
3272
  }
3500
3273
 
3501
3274
  // src/video-chat/video-chat.tsx
3502
- import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
3275
+ import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3503
3276
  var DESKTOP_WIDTH = 900;
3504
3277
  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);
3278
+ 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
3279
  useEffect9(() => {
3507
3280
  if (typeof window.matchMedia !== "function") return;
3508
3281
  const query = window.matchMedia(`(max-width: ${DESKTOP_WIDTH - 1}px) and (orientation: portrait)`);
@@ -3520,7 +3293,7 @@ var STEPS = [
3520
3293
  "Still filming\u2026"
3521
3294
  ];
3522
3295
  function useFilmingStep(active, key) {
3523
- const [step, setStep] = useState6(0);
3296
+ const [step, setStep] = useState7(0);
3524
3297
  useEffect9(() => {
3525
3298
  if (!active) return;
3526
3299
  setStep(0);
@@ -3530,47 +3303,38 @@ function useFilmingStep(active, key) {
3530
3303
  return STEPS[Math.min(step, STEPS.length - 1)];
3531
3304
  }
3532
3305
  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)) });
3306
+ 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
3307
  }
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);
3308
+ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice = false }) {
3309
+ const [dismissedNoticeTurn, setDismissedNoticeTurn] = useState7();
3310
+ const [draft, setDraft] = useState7("");
3311
+ const [savedSessions, setSavedSessions] = useState7([]);
3312
+ const [historyOpen, setHistoryOpen] = useState7(false);
3313
+ const [settingsOpen, setSettingsOpen] = useState7(false);
3314
+ const [aboutOpen, setAboutOpen] = useState7(false);
3315
+ const [captionsOn, setCaptionsOn] = useState7(true);
3316
+ const [captionsExpanded, setCaptionsExpanded] = useState7(false);
3317
+ const [alwaysShowControls, setAlwaysShowControls] = useState7(false);
3318
+ const [editing, setEditing] = useState7(false);
3319
+ const resumeAfterInput = useRef8(false);
3320
+ const panelRef = useRef8(null);
3321
+ const composerRef = useRef8(null);
3551
3322
  const viewportOrientation = useViewportOrientation();
3552
- const theme = themeById(themeId);
3553
3323
  const sessionOrientation = options.orientation ?? viewportOrientation;
3554
- const { brand: configuredBrand, style: configuredStyle, ...sessionOptions } = options;
3555
- const presentationBrand = configuredBrand ?? theme.brand;
3556
3324
  const { chat, restoreSession } = useVideoChatSession({
3557
- ...sessionOptions,
3558
- mode: modeId,
3325
+ ...options,
3559
3326
  orientation: sessionOrientation,
3560
- brand: presentationBrand,
3561
- style: { generatedLook: theme.generatedLook, ...configuredStyle }
3327
+ style: { generatedLook: "Natural light, restrained camera movement, documentary realism, consistent natural color. No embedded text.", ...options.style }
3562
3328
  });
3563
3329
  const instanceId = useId();
3564
3330
  const historyId = `${instanceId}-history`;
3565
3331
  const settingsId = `${instanceId}-settings`;
3566
3332
  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);
3333
+ const inputRef = useRef8(null);
3334
+ const historyRef = useRef8(null);
3335
+ const historyButtonRef = useRef8(null);
3336
+ const settingsRef = useRef8(null);
3337
+ const settingsButtonRef = useRef8(null);
3574
3338
  const listen = useVoiceInput(setDraft, chat.capabilities?.transcription ?? false, {
3575
3339
  endpoint: options.endpoint,
3576
3340
  headers: options.headers,
@@ -3623,17 +3387,19 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3623
3387
  const current = chat.currentTurn;
3624
3388
  const shown = chat.shownTurn;
3625
3389
  const showing = chat.playerProps != null;
3626
- const ambientPoster = shown?.openingMedia?.type === "image" ? shown.openingMedia.url : shown?.openingMedia?.posterUrl;
3390
+ const [openingAsset, setOpeningAsset] = useState7();
3391
+ const openingAssetKey = `${shown?.id ?? ""}:${shown?.openingMedia?.url ?? ""}`;
3392
+ const openingMediaFailed = openingAsset?.key === openingAssetKey && openingAsset.state === "failed";
3393
+ const openingMediaReady = openingAsset?.key === openingAssetKey && openingAsset.state === "ready";
3394
+ const openingChapter = !showing && Boolean(shown?.opening) && (!shown?.openingMedia || openingMediaFailed || !openingMediaReady);
3627
3395
  const waitingForPicture = chat.turns.length > 0 && !showing && (chat.status === "composing" || chat.status === "playing" || chat.status === "paused");
3628
3396
  const filmingStep = useFilmingStep(waitingForPicture, current?.id);
3629
3397
  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
3398
  const shownOrientation = shown?.orientation ?? sessionOrientation;
3631
3399
  const stageOrientation = shown?.fixedOrientation ? shownOrientation : sessionOrientation;
3632
- const availableModes = visualModes.filter((option) => chat.availableModes.includes(option.id));
3633
- const selectedMode = modeById(modeId);
3634
3400
  const line = chat.caption ?? "";
3635
3401
  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;
3402
+ 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
3403
  const cancelInput = useCallback6(() => {
3638
3404
  listen.stop();
3639
3405
  setDraft("");
@@ -3664,7 +3430,7 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3664
3430
  onFocusCapture: controls.onFocusCapture,
3665
3431
  onBlurCapture: controls.onBlurCapture
3666
3432
  };
3667
- useLayoutEffect(() => {
3433
+ useLayoutEffect2(() => {
3668
3434
  const composer = composerRef.current;
3669
3435
  if (!composer) return;
3670
3436
  const measure = () => panelRef.current?.style.setProperty("--composer-height", `${composer.getBoundingClientRect().height}px`);
@@ -3687,7 +3453,7 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3687
3453
  onKeyDownCapture: controls.reveal,
3688
3454
  children: [
3689
3455
  /* @__PURE__ */ jsxs6("header", { className: "chrome", ...controlEvents, children: [
3690
- /* @__PURE__ */ jsx7("div", { className: "session-brand", children: /* @__PURE__ */ jsx7(Logo, {}) }),
3456
+ /* @__PURE__ */ jsx8("div", { className: "session-brand", children: /* @__PURE__ */ jsx8(Logo, {}) }),
3691
3457
  /* @__PURE__ */ jsxs6("div", { className: "group", children: [
3692
3458
  /* @__PURE__ */ jsxs6(
3693
3459
  "button",
@@ -3704,12 +3470,12 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3704
3470
  setSettingsOpen(false);
3705
3471
  },
3706
3472
  children: [
3707
- /* @__PURE__ */ jsx7(Sessions, {}),
3708
- /* @__PURE__ */ jsx7("span", { className: "nav-label", children: "Sessions" })
3473
+ /* @__PURE__ */ jsx8(Sessions, {}),
3474
+ /* @__PURE__ */ jsx8("span", { className: "nav-label", children: "Sessions" })
3709
3475
  ]
3710
3476
  }
3711
3477
  ),
3712
- /* @__PURE__ */ jsx7(
3478
+ /* @__PURE__ */ jsx8(
3713
3479
  "button",
3714
3480
  {
3715
3481
  ref: settingsButtonRef,
@@ -3723,10 +3489,10 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3723
3489
  setSettingsOpen((open) => !open);
3724
3490
  setHistoryOpen(false);
3725
3491
  },
3726
- children: /* @__PURE__ */ jsx7(Gear, {})
3492
+ children: /* @__PURE__ */ jsx8(Gear, {})
3727
3493
  }
3728
3494
  ),
3729
- /* @__PURE__ */ jsx7(
3495
+ /* @__PURE__ */ jsx8(
3730
3496
  "button",
3731
3497
  {
3732
3498
  type: "button",
@@ -3734,27 +3500,37 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3734
3500
  "aria-label": chat.muted ? "Turn the voice on" : "Turn the voice off",
3735
3501
  "aria-pressed": chat.muted,
3736
3502
  onClick: () => chat.setMuted(!chat.muted),
3737
- children: chat.muted ? /* @__PURE__ */ jsx7(Muted, {}) : /* @__PURE__ */ jsx7(Sound, {})
3503
+ children: chat.muted ? /* @__PURE__ */ jsx8(Muted, {}) : /* @__PURE__ */ jsx8(Sound, {})
3738
3504
  }
3739
3505
  )
3740
3506
  ] })
3741
3507
  ] }),
3742
3508
  /* @__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: "" }) }),
3509
+ /* @__PURE__ */ jsxs6("div", { className: "stage", style: { background: "#000" }, children: [
3745
3510
  !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" })
3511
+ /* @__PURE__ */ jsx8("div", { className: "ground", "aria-hidden": "true" }),
3512
+ openingChapter && /* @__PURE__ */ jsx8(OpeningChapter, { title: shown.opening }, shown.id),
3513
+ shown?.openingMedia && !openingMediaFailed && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3514
+ /* @__PURE__ */ jsx8(
3515
+ Frame,
3516
+ {
3517
+ media: shown.openingMedia,
3518
+ poster: true,
3519
+ revealWhenReady: true,
3520
+ onReady: () => setOpeningAsset({ key: openingAssetKey, state: "ready" }),
3521
+ onError: () => setOpeningAsset({ key: openingAssetKey, state: "failed" })
3522
+ },
3523
+ openingAssetKey
3524
+ ),
3525
+ openingMediaReady && /* @__PURE__ */ jsx8("div", { className: "opening-wash", "aria-hidden": "true" })
3750
3526
  ] }),
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 })
3527
+ chat.turns.length === 0 && /* @__PURE__ */ jsx8(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
3528
+ shown?.prompt && !shown.opening && /* @__PURE__ */ jsxs6("div", { className: "asked", children: [
3529
+ /* @__PURE__ */ jsx8("p", { className: "asked-prompt", children: shown.prompt }),
3530
+ waitingForPicture && /* @__PURE__ */ jsx8("p", { className: "asked-step", "aria-live": "polite", children: filmingStep })
3755
3531
  ] })
3756
3532
  ] }),
3757
- chat.playerProps && /* @__PURE__ */ jsx7("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx7(
3533
+ chat.playerProps && /* @__PURE__ */ jsx8("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx8(
3758
3534
  VideoPlayer,
3759
3535
  {
3760
3536
  ...chat.playerProps,
@@ -3766,27 +3542,27 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3766
3542
  chat.playerKey
3767
3543
  ) }),
3768
3544
  showing && chat.playbackEnded && chat.suggestions.length > 0 && /* @__PURE__ */ jsxs6("div", { className: "ending", children: [
3769
- /* @__PURE__ */ jsx7("div", { className: "ending-wash", "aria-hidden": "true" }),
3545
+ /* @__PURE__ */ jsx8("div", { className: "ending-wash", "aria-hidden": "true" }),
3770
3546
  /* @__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 })
3547
+ /* @__PURE__ */ jsx8("p", { className: "ending-label", children: "Ask next" }),
3548
+ /* @__PURE__ */ jsx8(SuggestionCards, { suggestions: [...chat.suggestions], label: "Follow-up prompts", onAsk: ask })
3773
3549
  ] })
3774
3550
  ] })
3775
3551
  ] }),
3776
3552
  historyOpen && /* @__PURE__ */ jsxs6("nav", { ref: historyRef, id: historyId, className: "sheet-popover history", role: "dialog", "aria-modal": "true", "aria-label": "Sessions", children: [
3777
3553
  /* @__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, {}) })
3554
+ /* @__PURE__ */ jsx8("h2", { children: "Sessions" }),
3555
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Close sessions", onClick: closeHistory, children: /* @__PURE__ */ jsx8(Close, {}) })
3780
3556
  ] }),
3781
3557
  /* @__PURE__ */ jsxs6("button", { type: "button", className: "history-row session-new", "aria-label": "New session", onClick: newSession, children: [
3782
- /* @__PURE__ */ jsx7(Plus, {}),
3558
+ /* @__PURE__ */ jsx8(Plus, {}),
3783
3559
  /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3784
3560
  "New session",
3785
- /* @__PURE__ */ jsx7("small", { children: "Start a fresh conversation" })
3561
+ /* @__PURE__ */ jsx8("small", { children: "Start a fresh conversation" })
3786
3562
  ] })
3787
3563
  ] }),
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." }),
3564
+ /* @__PURE__ */ jsx8("h3", { className: "section-label", children: "Current session" }),
3565
+ chat.turns.length === 0 && /* @__PURE__ */ jsx8("p", { className: "history-empty", children: "Your questions will appear here." }),
3790
3566
  chat.turns.map((turn, index) => /* @__PURE__ */ jsxs6(
3791
3567
  "button",
3792
3568
  {
@@ -3803,17 +3579,17 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3803
3579
  setHistoryOpen(false);
3804
3580
  },
3805
3581
  children: [
3806
- /* @__PURE__ */ jsx7("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3582
+ /* @__PURE__ */ jsx8("span", { className: "index", children: String(index + 1).padStart(2, "0") }),
3807
3583
  /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3808
3584
  turn.prompt,
3809
- /* @__PURE__ */ jsx7("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3585
+ /* @__PURE__ */ jsx8("small", { children: turn.id === shown?.id ? "Now showing" : turn.completed ? "Play answer" : "Unfinished answer" })
3810
3586
  ] })
3811
3587
  ]
3812
3588
  },
3813
3589
  turn.id
3814
3590
  )),
3815
3591
  savedSessions.length > 0 && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3816
- /* @__PURE__ */ jsx7("h3", { className: "section-label", children: "Earlier sessions" }),
3592
+ /* @__PURE__ */ jsx8("h3", { className: "section-label", children: "Earlier sessions" }),
3817
3593
  savedSessions.map((session) => /* @__PURE__ */ jsxs6("button", { type: "button", className: "history-row", onClick: () => {
3818
3594
  listen.stop();
3819
3595
  setDraft("");
@@ -3825,7 +3601,7 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3825
3601
  setHistoryOpen(false);
3826
3602
  setCaptionsExpanded(false);
3827
3603
  }, children: [
3828
- /* @__PURE__ */ jsx7(Replay, {}),
3604
+ /* @__PURE__ */ jsx8(Replay, {}),
3829
3605
  /* @__PURE__ */ jsxs6("span", { className: "prompt", children: [
3830
3606
  session.turns[0]?.prompt,
3831
3607
  /* @__PURE__ */ jsxs6("small", { children: [
@@ -3848,72 +3624,55 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3848
3624
  "aria-modal": "true",
3849
3625
  children: [
3850
3626
  /* @__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, {}) })
3627
+ /* @__PURE__ */ jsx8("h2", { children: "Settings" }),
3628
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Close settings", onClick: closeSettings, children: /* @__PURE__ */ jsx8(Close, {}) })
3853
3629
  ] }),
3854
3630
  /* @__PURE__ */ jsxs6("fieldset", { className: "playback-options", children: [
3855
- /* @__PURE__ */ jsx7("legend", { children: "Watching" }),
3631
+ /* @__PURE__ */ jsx8("legend", { children: "Watching" }),
3856
3632
  /* @__PURE__ */ jsxs6("label", { className: "switch-row", children: [
3857
3633
  /* @__PURE__ */ jsxs6("span", { children: [
3858
- /* @__PURE__ */ jsx7("strong", { children: "Subtitles" }),
3859
- /* @__PURE__ */ jsx7("small", { children: "Read along with the answer" })
3634
+ /* @__PURE__ */ jsx8("strong", { children: "Subtitles" }),
3635
+ /* @__PURE__ */ jsx8("small", { children: "Read along with the answer" })
3860
3636
  ] }),
3861
- /* @__PURE__ */ jsx7("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3637
+ /* @__PURE__ */ jsx8("input", { type: "checkbox", role: "switch", checked: captionsOn, onChange: (event) => {
3862
3638
  setCaptionsOn(event.target.checked);
3863
3639
  setCaptionsExpanded(false);
3864
3640
  } })
3865
3641
  ] }),
3866
3642
  /* @__PURE__ */ jsxs6("label", { className: "switch-row", children: [
3867
3643
  /* @__PURE__ */ jsxs6("span", { children: [
3868
- /* @__PURE__ */ jsx7("strong", { children: "Keep controls visible" }),
3869
- /* @__PURE__ */ jsx7("small", { children: "Keep the input bar on screen" })
3644
+ /* @__PURE__ */ jsx8("strong", { children: "Keep controls visible" }),
3645
+ /* @__PURE__ */ jsx8("small", { children: "Keep the input bar on screen" })
3870
3646
  ] }),
3871
- /* @__PURE__ */ jsx7("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3647
+ /* @__PURE__ */ jsx8("input", { type: "checkbox", role: "switch", checked: alwaysShowControls, onChange: (event) => setAlwaysShowControls(event.target.checked) })
3872
3648
  ] })
3873
3649
  ] }),
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
3650
  /* @__PURE__ */ jsxs6("nav", { className: "developer-links", "aria-label": "Build with VanillaSky", children: [
3893
- /* @__PURE__ */ jsx7("p", { className: "section-label", children: "Build with VanillaSky" }),
3651
+ /* @__PURE__ */ jsx8("p", { className: "section-label", children: "Build with VanillaSky" }),
3894
3652
  /* @__PURE__ */ jsxs6("a", { href: "https://github.com/VanillaSkyAi/video/blob/main/docs/getting-started.md", target: "_blank", rel: "noopener noreferrer", children: [
3895
3653
  "Docs",
3896
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\u2197" })
3654
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2197" })
3897
3655
  ] }),
3898
3656
  /* @__PURE__ */ jsxs6("button", { type: "button", "aria-expanded": aboutOpen, "aria-controls": `${instanceId}-about`, onClick: () => setAboutOpen((open) => !open), children: [
3899
3657
  "About",
3900
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3658
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: aboutOpen ? "\u2212" : "+" })
3901
3659
  ] }),
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." }) }),
3660
+ /* @__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
3661
  /* @__PURE__ */ jsxs6("a", { href: "https://github.com/VanillaSkyAi/video", target: "_blank", rel: "noopener noreferrer", children: [
3904
3662
  "GitHub",
3905
- /* @__PURE__ */ jsx7("span", { "aria-hidden": "true", children: "\u2197" })
3663
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2197" })
3906
3664
  ] })
3907
3665
  ] })
3908
3666
  ]
3909
3667
  }
3910
3668
  )
3911
3669
  ] }),
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(
3670
+ /* @__PURE__ */ jsx8("div", { ref: panelRef, className: "panel", "data-input-visible": controls.visible || !captionsOn || !line, children: /* @__PURE__ */ jsxs6("div", { className: "panel-inner", children: [
3671
+ /* @__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
3672
  "div",
3915
3673
  {
3916
3674
  className: "line-row",
3675
+ "data-opening-copy": openingChapter && line === shown?.opening && !captionsExpanded,
3917
3676
  "data-expanded": captionsExpanded,
3918
3677
  "data-actions-visible": captionControls.visible,
3919
3678
  onPointerMove: captionControls.onPointerEnter,
@@ -3925,41 +3684,41 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3925
3684
  captionsOn && line && /* @__PURE__ */ jsxs6("div", { className: "caption-actions", children: [
3926
3685
  /* @__PURE__ */ jsxs6("button", { type: "button", className: "caption-action", "aria-label": captionsExpanded ? "Collapse subtitles" : "Expand subtitles", "aria-expanded": captionsExpanded, onClick: () => setCaptionsExpanded((open) => !open), children: [
3927
3686
  captionsExpanded ? "Collapse" : "Expand",
3928
- /* @__PURE__ */ jsx7(ChevronUp, {})
3687
+ /* @__PURE__ */ jsx8(ChevronUp, {})
3929
3688
  ] }),
3930
- /* @__PURE__ */ jsx7("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
3689
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "caption-action", "aria-label": "Hide subtitles", onClick: () => {
3931
3690
  setCaptionsOn(false);
3932
3691
  setCaptionsExpanded(false);
3933
- }, children: /* @__PURE__ */ jsx7(Close, {}) })
3692
+ }, children: /* @__PURE__ */ jsx8(Close, {}) })
3934
3693
  ] }),
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 })
3694
+ 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
3695
  ]
3937
3696
  }
3938
3697
  ) }) }),
3939
3698
  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, {}) })
3699
+ /* @__PURE__ */ jsx8("p", { role: "status", children: MEDIA_RECOVERY_NOTICE }),
3700
+ /* @__PURE__ */ jsx8("button", { type: "button", className: "round", "aria-label": "Dismiss notice", onClick: () => setDismissedNoticeTurn(chat.shownTurn?.id), children: /* @__PURE__ */ jsx8(Close, {}) })
3942
3701
  ] }),
3943
3702
  (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 })
3703
+ /* @__PURE__ */ jsx8(Warning, {}),
3704
+ /* @__PURE__ */ jsx8("span", { children: chat.error?.message ?? listen.error })
3946
3705
  ] }),
3947
3706
  /* @__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" }) }),
3707
+ (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
3708
  /* @__PURE__ */ jsxs6("form", { className: "composer", "aria-label": "Ask a question", onSubmit: (event) => {
3950
3709
  event.preventDefault();
3951
3710
  ask(draft);
3952
3711
  }, children: [
3953
- transport && /* @__PURE__ */ jsx7("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
3712
+ transport && /* @__PURE__ */ jsx8("button", { type: "button", className: "ghost transport", "aria-label": transport.label, onClick: () => {
3954
3713
  listen.stop();
3955
3714
  setEditing(false);
3956
3715
  resumeAfterInput.current = false;
3957
3716
  inputRef.current?.blur();
3958
3717
  transport.action();
3959
3718
  }, 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(
3719
+ /* @__PURE__ */ jsx8(Waveform, { active: listen.listening || chat.speaking && !chat.muted && status !== "paused", listening: listen.listening }),
3720
+ /* @__PURE__ */ jsx8("label", { className: "sr-only", htmlFor: promptId, children: "Prompt" }),
3721
+ /* @__PURE__ */ jsx8(
3963
3722
  "textarea",
3964
3723
  {
3965
3724
  id: promptId,
@@ -3987,12 +3746,12 @@ function VideoChat({ options = {}, className, welcomeTitle, generatedVideoLabel,
3987
3746
  }
3988
3747
  }
3989
3748
  ),
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, {}) })
3749
+ 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, {}) }),
3750
+ /* @__PURE__ */ jsx8("button", { type: "submit", className: "send", "aria-label": "Ask", disabled: !draft.trim() || listen.thinking, children: /* @__PURE__ */ jsx8(Send, {}) }),
3751
+ editing && /* @__PURE__ */ jsx8("button", { type: "button", className: "ghost", "aria-label": "Cancel question", onClick: cancelInput, children: /* @__PURE__ */ jsx8(Close, {}) })
3993
3752
  ] })
3994
3753
  ] }),
3995
- !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx7("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
3754
+ !showing && chat.turns.length === 0 && /* @__PURE__ */ jsx8("p", { className: "dock-hint", children: "Speak or type. See where it takes you." })
3996
3755
  ] }) })
3997
3756
  ]
3998
3757
  }