@vanillaskyai/video 0.10.2 → 0.10.4

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 (48) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/PUBLIC-API.md +43 -31
  3. package/README.md +18 -9
  4. package/dist/check-runtime.js +2 -2
  5. package/dist/{chunk-KHLO5OHT.js → chunk-G5TYLTL5.js} +1 -1
  6. package/dist/{chunk-77VY4O7A.js → chunk-O6FBIB4L.js} +58 -10
  7. package/dist/{chunk-TVIU23OM.js → chunk-OOBT4X46.js} +13 -3
  8. package/dist/{chunk-5C6HZNHY.js → chunk-QZAZT44G.js} +3 -2
  9. package/dist/{chunk-6KZGF63O.js → chunk-WZFLEPLM.js} +58 -35
  10. package/dist/{chunk-VQH3JTQC.js → chunk-Z2ZI5G7O.js} +1 -1
  11. package/dist/{cinema-media-APDJS7SC.js → cinema-media-3SIGVVCY.js} +4 -4
  12. package/dist/{comparison-ZVE2DE7U.js → comparison-VDGRWPB5.js} +4 -4
  13. package/dist/{editorial-timeline-YIXXVJUV.js → editorial-timeline-QJU4JJWB.js} +4 -4
  14. package/dist/{key-figure-I2C37FWV.js → key-figure-YUAQW6OE.js} +4 -4
  15. package/dist/{mobile-message-JSGBU6EX.js → mobile-message-ZMMTUDUQ.js} +4 -4
  16. package/dist/{quote-XH54G3FS.js → quote-BB6UJT6L.js} +4 -4
  17. package/dist/react.d.ts +3 -1
  18. package/dist/react.js +94 -84
  19. package/dist/{scene-video-backdrop-PCTARAAJ.js → scene-video-backdrop-4EZVVQY7.js} +2 -2
  20. package/dist/server.d.ts +15 -3
  21. package/dist/server.js +416 -275
  22. package/dist/{types-CdBmNTGD.d.ts → types-BqB8zC9u.d.ts} +1 -1
  23. package/docs/agent-integration.md +5 -4
  24. package/docs/concepts.md +2 -2
  25. package/docs/development.md +23 -0
  26. package/docs/getting-started.md +7 -5
  27. package/docs/media-and-audio.md +37 -18
  28. package/docs/performance.md +21 -10
  29. package/docs/production.md +11 -10
  30. package/docs/prompt-and-input.md +24 -15
  31. package/docs/provider-integration.md +11 -13
  32. package/docs/reference/protocol.md +7 -1
  33. package/docs/reference/provider-adapters.md +7 -4
  34. package/docs/testing.md +7 -2
  35. package/package.json +5 -2
  36. package/registry/items/backgrounds.json +2 -2
  37. package/registry/items/cinemaMedia.json +1 -1
  38. package/registry/items/comparison.json +1 -1
  39. package/registry/items/editorialTimeline.json +1 -1
  40. package/registry/items/keyFigure.json +1 -1
  41. package/registry/items/mobileMessage.json +1 -1
  42. package/registry/items/quote.json +1 -1
  43. package/starters/video-chat/README.md +25 -26
  44. package/starters/video-chat/package.json +1 -1
  45. package/starters/video-chat/providers/video.ts +10 -6
  46. package/starters/video-chat/server.ts +1 -1
  47. package/starters/video-chat/stock.ts +59 -31
  48. package/styles/video-chat.css +6 -61
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.10.4
8
+
9
+ - Show the canonical chapter immediately after submitting a prompt; remove the separate gradient loading placeholder and opening stock requests.
10
+ - Separate AI video and Pexels in Settings. AI mode uses generated footage only; Pexels performs full bounded video search without AI-video calls. Missing or late footage becomes an authored chapter with complete narration.
11
+ - Prepare speech while footage generates, reuse prepared lines, and limit concurrent speech requests so an answer does not flood host admission. Describe HTTP throttling as a retryable request limit.
12
+ - Keep silent footage looping for the finite narrated scene, and recover missing URLs or rejected playback to a chapter rather than an unavailable screen. Preserve user pause, cancellation and replay.
13
+ - Allow hosts to configure the actual generated clip duration (default five seconds) and apply scene-position deadlines to media preparation.
14
+ - Add optional host-only phase diagnostics with bounded timing and fixed recovery reasons, without retaining prompt or provider content.
15
+
16
+ ## 0.10.3
17
+
18
+ - Remove the implicit documentary visual style from the chat interface; preserve explicitly supplied application styles and let the answer direct its own look.
19
+ - Default chat now plans an answer brief and narrated shots, with an immediate introduction followed by AI-first footage and relevant stock fallback. Creative direction adapts to the request; the model no longer chooses body templates, media sources, scene IDs, or completion commands. Explicit custom template registries retain their existing composition contract.
20
+ - Reserve the answer's ending before streaming body shots, retain valid narration when visuals are unavailable, and skip exact repeated opening or ending lines before requesting media.
21
+ - Fit short clips to measured narration within a bounded playback rate. If moving coverage runs out, use bounded recovery and an explicit waiting state instead of holding a finished frame while speech continues. Preserve deliberate pauses and audio activation across scenes.
22
+ - The optional starter video adapter uses Fal Turbo with five-second clips and intent-specific shot direction; providers, credentials, admission and spending limits remain application-owned.
23
+
7
24
  ## 0.10.2
8
25
 
9
26
  - Keep generated narration playing in Safari after a delayed opening-to-body handoff by reusing the permitted audio element; isolate late playback failures from subsequent lines.
package/PUBLIC-API.md CHANGED
@@ -116,6 +116,19 @@ provider-neutral text-delta escape hatch.
116
116
 
117
117
  ### Handler contract
118
118
 
119
+ The chat contract includes two footage modes: `"cinematic"` (labelled AI video)
120
+ and `"pexels"`. AI mode does not fall back to stock; either mode recovers missing
121
+ footage to an authored chapter. Existing `"cinematic"` requests remain valid.
122
+ `generatedClipDurationSec?: number` on the handler defaults to 5 and accepts 2–20;
123
+ it must match the host adapter's requested duration. `VideoChatTurn.mode` is
124
+ optional so older in-memory saved turns remain readable.
125
+
126
+ A declared `data.video-chat-preparation` extension carries
127
+ `{ sceneId: string, narration: string }` before media resolves. It permits bounded
128
+ speech preparation, not playback of an unvalidated scene. The client limits
129
+ speech preparation to two concurrent jobs and reuses it when `scene.add` arrives.
130
+ The six public exports, persisted video schema and protocol version are unchanged.
131
+
119
132
  `createVideoChatHandler` is the opinionated general-purpose video-chat route.
120
133
  Mount it once and use its bounded `action` query parameter for capabilities,
121
134
  responses, opening media, narration, suggestions, speech,
@@ -124,33 +137,28 @@ application-configured visual-mode attempt limits, capability fallbacks, and aux
124
137
  shapes. The application supplies provider-neutral `streamText`, `generateText`,
125
138
  `generateSpeech`, `transcribe`, `searchMedia`, and `generateVideo` callbacks.
126
139
  Only the two text callbacks are required. Missing optional callbacks remove
127
- their capability; templates and browser speech remain available. During response
128
- creation, failed generated footage falls back to stock when available, then to
129
- matching completed footage and finally a safe template. The default handler skips invalid planner parts and preserves
130
- playable scenes on an interrupted plan, emitting non-fatal warnings. Explicit
131
- `invalidPartBehavior: "fail"` retains strict generation semantics.
140
+ their capability; templates and browser speech remain available. Failed or late
141
+ footage becomes an authored chapter with complete narration. The default handler
142
+ skips invalid planner parts and preserves playable scenes on an interrupted plan,
143
+ emitting non-fatal warnings. Explicit `invalidPartBehavior: "fail"` retains strict
144
+ generation semantics.
132
145
 
133
146
  - A response accepts `prompt`, `mode`, `orientation`, optional bounded
134
147
  `conversation`, `opening`, and `style`. `opening` is an optional
135
148
  prewritten hook from a selected suggestion. The response returns protocol
136
- `0.6` SSE and negotiates `data.video-chat-opening`, which carries the bounded
137
- 6-9 word hook and optional stock-search `keyword` and `fallbackKeyword` before the first scene.
138
- - The planner produces that opening as the first line of the same model stream
139
- that produces the scenes. The separate opening-media action resolves its
140
- keyword through the application-owned `searchMedia` callback, with an optional
141
- bounded `fallbackQuery` for broader opening atmosphere. Stock lookup
142
- never delays speech or planning. In `full` mode the first line also directs
143
- the exact first generated scene; the handler consumes that private direction
144
- and starts the clip while the model continues with scenes two through five.
145
- - `templates` never generates video. `full` uses an application-owned
146
- `maxGeneratedVideos` limit (default five, nonnegative safe integer). The limit
147
- counts attempts including failed attempts and the reserved first shot; it is
148
- neither a currency nor duration cap. Stock lookup remains available after
149
- the limit. With zero, full mode uses stock only. The planner receives this
150
- budget, and the server enforces it independently. If providers fail, an
151
- already completed video with the identical search query and visual look may
152
- be reused once in the same response before a readable template fallback. Without `generateVideo`, only `templates` is exposed
153
- and forged generated-mode requests degrade to it.
149
+ `0.6` SSE and negotiates `data.video-chat-opening` and
150
+ `data.video-chat-preparation` before the associated scene is ready.
151
+ - The planner produces the opening and shots in one model stream, reserving the
152
+ intended ending. Each shot supplies an authored recovery title, complete
153
+ narration and visual direction. The default UI uses a chapter opening; the
154
+ separate opening-media action remains available to custom consumers.
155
+ - `cinematic` uses the application-owned `maxGeneratedVideos` limit (default
156
+ five, nonnegative safe integer). It counts generation attempts, including
157
+ failures. Zero skips generation and keeps authored chapters. Expired scene
158
+ deadlines also skip generation without consuming an attempt. This limit is
159
+ neither a currency nor duration cap. Pexels mode uses only `searchMedia` and
160
+ never consumes this allowance. Capabilities expose Pexels when stock search
161
+ is configured; AI mode remains usable with chapter recovery without video.
154
162
  - The response planner writes narration on each scene. The narration action is
155
163
  retained as a compatibility fallback for missing lines, not used in the
156
164
  normal path.
@@ -251,9 +259,9 @@ playable output; an error is shown only when no playable response remains.
251
259
  `ask(prompt, { opening, openingMedia })` lets a custom interface start a
252
260
  prewritten suggestion hook immediately and reuse its image or video without
253
261
  another model or media lookup. The hook otherwise reads the opening from the
254
- response stream, resolves its media keyword, holds that media while it is
255
- spoken, and starts the planned timeline only when both speech and the first
256
- scene are ready. `UseVideoChatOptions.onFirstFrame` receives a
262
+ response stream and starts the planned timeline only when opening speech and
263
+ the contiguous prepared scenes are ready. It no longer requests opening stock
264
+ automatically. `UseVideoChatOptions.onFirstFrame` receives a
257
265
  `VideoChatFirstFrameMetric` once after its first active scene commits and reaches
258
266
  an animation-frame opportunity. This measures presentation readiness, not physical
259
267
  screen paint or media decoding. Each `VideoChatTurn` exposes `openingMedia` and `completed`, so
@@ -428,6 +436,7 @@ from `/server` without crossing a React type boundary.
428
436
  and nonnegative `elapsedMs` since the prompt was submitted:
429
437
 
430
438
  - `first-frame`: first committed active scene at an animation-frame opportunity;
439
+ - `first-media-frame`: first decoded frame reported by the mounted footage surface;
431
440
  - `first-speech`: actual speech playback onset, with `source` equal to `browser`,
432
441
  `generated`, or `custom`;
433
442
  - `stall`: a finished wait for the next prepared scene, with `durationMs` and
@@ -455,8 +464,11 @@ resolver deadline (integer 1–120000 milliseconds, default 15000). The host sti
455
464
  provider cancellation, authorization and spend limits; a timeout does not prove the
456
465
  provider avoided a charge. `maxGeneratedVideos` continues to count attempted clips.
457
466
 
458
- `VideoChatProps.generatedVideoLabel` and `generatedVideoDescription` customize the
459
- existing generated-video Settings choice without changing server capabilities.
460
- `showRecoveryNotice` opts into a dismissible, fixed SDK media-fallback notice. It
461
- never displays arbitrary provider messages. All three default to existing behavior.
462
- These are additive members; no new entry points or exports are introduced.
467
+ `VideoChatProps.showRecoveryNotice` opts into a dismissible, fixed SDK
468
+ media-recovery notice; it never displays arbitrary provider messages.
469
+
470
+ `VideoChatHandlerOptions.onDiagnostic` is an optional host-only observer for
471
+ request, authoring and media phase timings with fixed recovery reason codes.
472
+ It does not retain prompt, narration, query, asset URL or provider error text,
473
+ and cannot affect response delivery. No new entry points or named exports are
474
+ introduced; its additive signature is checked in the packed consumer contract.
package/README.md CHANGED
@@ -25,9 +25,9 @@ npx vanillasky doctor
25
25
  npm run dev
26
26
  ```
27
27
 
28
- Open the reported localhost URL. One text key gives you the complete chat with
29
- packaged templates and browser voice. No template setup or optional provider
30
- packages are required. If installation is interrupted, rerun the init command.
28
+ Open the reported localhost URL. One text key gives you the chat, an immediate introduction, and browser voice.
29
+ Add the video adapter for AI footage, or a Pexels key for the lower-cost stock mode.
30
+ Without footage, authored chapter scenes carry the narration and subtitles. If installation is interrupted, rerun the init command.
31
31
 
32
32
  Optional upgrades keep the same client:
33
33
 
@@ -35,7 +35,7 @@ Optional upgrades keep the same client:
35
35
  | --- | --- | --- |
36
36
  | Generated speech | `npx vanillasky providers add speech` | `XAI_API_KEY` |
37
37
  | Generated video and voice transcription | `npx vanillasky providers add video` | `FAL_KEY` |
38
- | Stock media, including opening backgrounds | No install needed | `PEXELS_API_KEY` |
38
+ | Pexels video search | No install needed | `PEXELS_API_KEY` |
39
39
 
40
40
  Run the selected command, add its key to `.env.local`, and restart the server.
41
41
  Only selected provider packages are installed. `npx vanillasky doctor` reports
@@ -80,17 +80,25 @@ conversation and playback lifecycle. Edit the generated server when you want a
80
80
  different provider. The [provider guide](docs/provider-integration.md) explains
81
81
  both boundaries.
82
82
 
83
- ## Templates are the built-in fallback
83
+ ## An immediate intro, then moving footage
84
84
 
85
- The packaged visual templates are the fast, inexpensive default and require no
86
- copied source tree. Add a video provider when you want generated footage; the
87
- same conversation can mix both modes.
85
+ The default chat uses a template introduction while its first shot prepares,
86
+ then footage with narration and subtitles. Choose AI video or Pexels in Settings.
87
+ AI mode never substitutes stock, and Pexels mode never calls the AI-video provider.
88
+ Missing or late footage becomes a useful chapter scene. The planner adapts its
89
+ visible actions and spoken beats to explanations, stories, comedy, imagination,
90
+ and practical requests. It does not choose body templates.
91
+
92
+ Speech and footage prepare together. Silent clips loop through the remaining
93
+ narration when necessary, and every answer preserves its intended ending.
94
+
95
+ Packaged templates remain available for custom compositions and source ownership.
88
96
 
89
97
  Copy template source only when you want to own and edit it:
90
98
 
91
99
  ```bash
92
100
  npm install --save-dev tsx
93
- npx vanillasky templates add bigNumber
101
+ npx vanillasky templates add chapterTitle
94
102
  ```
95
103
 
96
104
  That compiler is needed only for source-owned templates. See
@@ -113,6 +121,7 @@ MP4/WebM export remains application-owned.
113
121
  | Add media or voice | [Media and voice](docs/media-and-audio.md) |
114
122
  | Persist and replay results | [Performance measurements](docs/performance.md) · [Persistence and replay](docs/persistence.md) |
115
123
  | Test routes and streams | [Test integrations](docs/testing.md) |
124
+ | Iterate on SDK chat | [Development](docs/development.md): `dev:chat`, `check:chat`, `verify:release` |
116
125
  | Deploy securely | [Production](docs/production.md) · [Security](docs/security.md) |
117
126
  | Inspect the API contract | [Public API](PUBLIC-API.md) · [Protocol](docs/reference/protocol.md) |
118
127
 
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  VideoFrame
3
- } from "./chunk-6KZGF63O.js";
3
+ } from "./chunk-WZFLEPLM.js";
4
4
  import "./chunk-224QNWRA.js";
5
- import "./chunk-TVIU23OM.js";
5
+ import "./chunk-OOBT4X46.js";
6
6
  import "./chunk-SPVTJH3F.js";
7
7
  import "./chunk-R3XAOMKP.js";
8
8
  import "./chunk-73NTSFFI.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-5C6HZNHY.js";
4
+ } from "./chunk-QZAZT44G.js";
5
5
  import {
6
6
  editorialFont
7
7
  } from "./chunk-4YM2M62S.js";
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  darken,
3
3
  resolveTokens,
4
- useMediaAudio
5
- } from "./chunk-TVIU23OM.js";
4
+ useMediaAudio,
5
+ useMediaFailure,
6
+ useNarrationPreroll
7
+ } from "./chunk-OOBT4X46.js";
6
8
 
7
9
  // src/visual-system/scene-templates/scene-video-backdrop.tsx
8
- import { useEffect, useRef, useState } from "react";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
9
11
 
10
12
  // src/visual-system/scene-templates/color-utils.ts
11
13
  import React from "react";
@@ -228,6 +230,8 @@ var SceneVideoBackdrop = ({
228
230
  mediaPosition = "center",
229
231
  backgroundEffect,
230
232
  progress,
233
+ sceneDuration,
234
+ preparingNarration = false,
231
235
  beatIntensity = 0,
232
236
  isPlaying,
233
237
  muted,
@@ -240,15 +244,44 @@ var SceneVideoBackdrop = ({
240
244
  onError
241
245
  }) => {
242
246
  const inheritedAudio = useMediaAudio();
247
+ const reportMediaFailure = useMediaFailure();
248
+ const inheritedPreroll = useNarrationPreroll();
249
+ const rewindPreroll = preparingNarration || inheritedPreroll;
243
250
  const resolvedMuted = muted ?? inheritedAudio.muted;
244
251
  const resolvedVolume = volume ?? inheritedAudio.volume;
245
252
  const resolvedPosition = resolveMediaPosition(mediaPosition);
246
253
  const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);
247
254
  const [decodedVideoUrl, setDecodedVideoUrl] = useState();
255
+ const [waitingKey, setWaitingKey] = useState();
256
+ const [exhaustedKey, setExhaustedKey] = useState();
248
257
  const videoRef = useRef(null);
249
258
  const startedVideoUrl = useRef(void 0);
250
259
  const startedPlaybackId = useRef(void 0);
251
260
  const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
261
+ const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });
262
+ presentationRef.current = { key: videoPresentationKey, playing: isPlaying };
263
+ const unavailable = () => {
264
+ if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {
265
+ setExhaustedKey(videoPresentationKey);
266
+ onError?.();
267
+ reportMediaFailure?.();
268
+ }
269
+ };
270
+ const fitDuration = useCallback((video) => {
271
+ video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0 ? Math.max(0.75, Math.min(1, video.duration / (sceneDuration + 0.2))) : 1;
272
+ }, [resolvedMuted, sceneDuration]);
273
+ useEffect(() => {
274
+ if (videoRef.current) fitDuration(videoRef.current);
275
+ }, [fitDuration]);
276
+ const continueMotion = (video) => {
277
+ if (!isPlaying) return;
278
+ if (!resolvedMuted) {
279
+ unavailable();
280
+ return;
281
+ }
282
+ video.currentTime = 0;
283
+ void video.play().catch(unavailable);
284
+ };
252
285
  useEffect(() => {
253
286
  const video = videoRef.current;
254
287
  if (!video) return;
@@ -273,21 +306,21 @@ var SceneVideoBackdrop = ({
273
306
  if (!video) return;
274
307
  if (!isPlaying) {
275
308
  video.pause();
309
+ if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;
276
310
  return;
277
311
  }
278
312
  if (startedPlaybackId.current === playbackId) {
279
- if (!video.ended) void video.play().catch(() => {
280
- });
313
+ if (video.ended) continueMotion(video);
314
+ else void video.play().catch(unavailable);
281
315
  return;
282
316
  }
283
317
  const changingSource = startedVideoUrl.current !== void 0 && startedVideoUrl.current !== mediaUrl;
284
- video.playbackRate = 1;
318
+ fitDuration(video);
285
319
  if (!changingSource && video.currentTime > 0) video.currentTime = 0;
286
- video.play().catch(() => {
287
- });
320
+ video.play().catch(unavailable);
288
321
  startedVideoUrl.current = mediaUrl;
289
322
  startedPlaybackId.current = playbackId;
290
- }, [isPlaying, mediaUrl, playbackId]);
323
+ }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);
291
324
  const mediaStyle = {
292
325
  position: "absolute",
293
326
  inset: 0,
@@ -349,6 +382,15 @@ var SceneVideoBackdrop = ({
349
382
  },
350
383
  posterPlane.presentationKey
351
384
  )),
385
+ (exhaustedKey === videoPresentationKey || waitingKey === videoPresentationKey) && /* @__PURE__ */ jsx(
386
+ "div",
387
+ {
388
+ role: "status",
389
+ "data-media-continuity": exhaustedKey === videoPresentationKey ? "exhausted" : "waiting",
390
+ style: { position: "absolute", inset: 0, zIndex: 3, background: "#000", color: "#bbb", display: "grid", placeContent: "center", font: "14px system-ui" },
391
+ children: exhaustedKey === videoPresentationKey ? "Visual unavailable" : "Loading visual"
392
+ }
393
+ ),
352
394
  /* @__PURE__ */ jsx(
353
395
  "video",
354
396
  {
@@ -359,6 +401,12 @@ var SceneVideoBackdrop = ({
359
401
  loop: false,
360
402
  playsInline: true,
361
403
  preload: "auto",
404
+ onLoadedMetadata: (event) => fitDuration(event.currentTarget),
405
+ onEnded: (event) => continueMotion(event.currentTarget),
406
+ onWaiting: () => {
407
+ if (isPlaying) setWaitingKey(videoPresentationKey);
408
+ },
409
+ onPlaying: () => setWaitingKey(void 0),
362
410
  onLoadedData: (event) => {
363
411
  const video = event.currentTarget;
364
412
  const markPresented = () => {
@@ -375,7 +423,7 @@ var SceneVideoBackdrop = ({
375
423
  onError,
376
424
  "data-media-position": mediaPosition,
377
425
  "data-video-backdrop": persistent ? "persistent" : "scene",
378
- style: mediaStyle
426
+ style: { ...mediaStyle, visibility: exhaustedKey === videoPresentationKey || waitingKey === videoPresentationKey ? "hidden" : void 0 }
379
427
  }
380
428
  )
381
429
  ] });
@@ -8,11 +8,13 @@ function ExternalVideoBackdropProvider({
8
8
  mode,
9
9
  audioMuted = true,
10
10
  audioVolume = 1,
11
+ preparingNarration = false,
12
+ onMediaError,
11
13
  children
12
14
  }) {
13
15
  const value = React.useMemo(
14
- () => ({ mode, audioMuted, audioVolume }),
15
- [mode, audioMuted, audioVolume]
16
+ () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),
17
+ [mode, audioMuted, audioVolume, preparingNarration, onMediaError]
16
18
  );
17
19
  return /* @__PURE__ */ jsx(BackdropContext.Provider, { value, children });
18
20
  }
@@ -23,6 +25,12 @@ function useMediaAudio() {
23
25
  const { audioMuted, audioVolume } = React.useContext(BackdropContext);
24
26
  return { muted: audioMuted, volume: audioVolume };
25
27
  }
28
+ function useNarrationPreroll() {
29
+ return React.useContext(BackdropContext).preparingNarration === true;
30
+ }
31
+ function useMediaFailure() {
32
+ return React.useContext(BackdropContext).onMediaError;
33
+ }
26
34
 
27
35
  // src/visual-system/scene-templates/tokens.ts
28
36
  var STYLE_PRESETS = {
@@ -142,5 +150,7 @@ export {
142
150
  darken,
143
151
  ExternalVideoBackdropProvider,
144
152
  useExternalVideoBackdrop,
145
- useMediaAudio
153
+ useMediaAudio,
154
+ useNarrationPreroll,
155
+ useMediaFailure
146
156
  };
@@ -3,13 +3,13 @@ import {
3
3
  SceneVideoBackdrop,
4
4
  getBackgroundTransform,
5
5
  resolveMediaPosition
6
- } from "./chunk-77VY4O7A.js";
6
+ } from "./chunk-O6FBIB4L.js";
7
7
  import {
8
8
  resolveMediaType
9
9
  } from "./chunk-224QNWRA.js";
10
10
  import {
11
11
  useExternalVideoBackdrop
12
- } from "./chunk-TVIU23OM.js";
12
+ } from "./chunk-OOBT4X46.js";
13
13
 
14
14
  // src/visual-system/scene-templates/scene-background.tsx
15
15
  import { useEffect, useState } from "react";
@@ -157,6 +157,7 @@ var SceneBackground = ({
157
157
  mediaPosition,
158
158
  backgroundEffect,
159
159
  progress,
160
+ sceneDuration,
160
161
  beatIntensity,
161
162
  isPlaying,
162
163
  onReady: () => setMediaPaint("ready"),
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  ExternalVideoBackdropProvider,
7
7
  resolveDensity
8
- } from "./chunk-TVIU23OM.js";
8
+ } from "./chunk-OOBT4X46.js";
9
9
  import {
10
10
  resolveVideoTimeline
11
11
  } from "./chunk-SPVTJH3F.js";
@@ -209,7 +209,7 @@ var SceneBoundary = class extends Component {
209
209
  }
210
210
  };
211
211
  var SCENE_TRANSITION_SECONDS = 0.3;
212
- var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-PCTARAAJ.js").then((module) => ({ default: module.SceneVideoBackdrop })));
212
+ var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-4EZVVQY7.js").then((module) => ({ default: module.SceneVideoBackdrop })));
213
213
  var MEDIA_PREROLL_SECONDS = 1.2;
214
214
  var CONTIGUITY_ULP_FACTOR = 4;
215
215
  var subscribeToDecoderPolicy = () => () => {
@@ -257,6 +257,7 @@ function sceneBackgroundChanges(left, right) {
257
257
  }
258
258
  function SceneLayer({
259
259
  onFramePresented,
260
+ onMediaError,
260
261
  kit,
261
262
  config,
262
263
  range,
@@ -265,6 +266,7 @@ function SceneLayer({
265
266
  width,
266
267
  height,
267
268
  playing,
269
+ preparingNarration,
268
270
  mediaAudioMuted,
269
271
  mediaAudioVolume,
270
272
  layer,
@@ -273,7 +275,7 @@ function SceneLayer({
273
275
  zIndex,
274
276
  externalVideoBackdrop
275
277
  }) {
276
- const mediaFailed = externalVideoBackdrop === "fallback";
278
+ const mediaFailed = externalVideoBackdrop === "fallback" || range.scene.templateId === "cinemaMedia" && !String(range.scene.variables.mediaUrl || "").trim();
277
279
  const recoveryTitle = mediaFailed && range.scene.templateId === "cinemaMedia" && typeof range.scene.variables.fallbackText === "string" ? range.scene.variables.fallbackText : void 0;
278
280
  const template = kit.getTemplate(recoveryTitle ? "chapterTitle" : range.scene.templateId);
279
281
  const duration = range.end - range.start;
@@ -283,7 +285,9 @@ function SceneLayer({
283
285
  mode: externalVideoBackdrop,
284
286
  audioMuted: mediaAudioMuted || !playing,
285
287
  audioVolume: mediaAudioVolume,
286
- children: /* @__PURE__ */ jsx2(
288
+ preparingNarration,
289
+ onMediaError,
290
+ children: /* @__PURE__ */ jsxs(
287
291
  "div",
288
292
  {
289
293
  "data-scene-layer": layer,
@@ -304,37 +308,48 @@ function SceneLayer({
304
308
  "--vanillasky-transition-semantic-visibility": layer === "incoming" ? "hidden" : "visible",
305
309
  "--vanillasky-template-surface": externalVideoBackdrop !== false ? "transparent" : void 0
306
310
  },
307
- children: template ? /* @__PURE__ */ jsx2(SceneBoundary, { scene: range.scene, onFramePresented, children: /* @__PURE__ */ jsxs(Suspense, { fallback: /* @__PURE__ */ jsx2(
308
- "div",
309
- {
310
- "data-template-loading": range.scene.templateId,
311
- style: { position: "absolute", inset: 0 }
312
- }
313
- ), children: [
314
- /* @__PURE__ */ jsx2(PresentedScene, { notify: onFramePresented }),
315
- createElement(template.component, {
316
- variables: {
317
- ...template.defaults ?? getTemplateDefaults(template.schema),
318
- ...range.scene.variables,
319
- ...mediaFailed ? { mediaUrl: "", mediaPoster: "", mediaType: "gradient" } : {},
320
- ...recoveryTitle ? { title: recoveryTitle } : {}
321
- },
322
- style: config.style,
323
- progress,
324
- motionProgress,
325
- beatIntensity: 0,
326
- width,
327
- height,
328
- textArchetype: range.scene.textArchetype ?? config.style.defaultTextArchetype,
329
- backgroundEffect: range.scene.backgroundEffect ?? config.style.defaultBackgroundEffect,
330
- safeZone: scaleSafeZone(
331
- getSafeZone(config.orientation),
332
- resolveDensity(config.style.density).safeZoneScale
333
- ),
334
- sceneDuration: duration,
335
- isPlaying: playing
336
- })
337
- ] }) }, range.scene.id) : /* @__PURE__ */ jsx2(SafeScene, { scene: range.scene, onFramePresented })
311
+ children: [
312
+ range.scene.templateId === "cinemaMedia" && !recoveryTitle && (mediaFailed || !String(range.scene.variables.mediaUrl || "").trim()) && /* @__PURE__ */ jsx2(
313
+ "div",
314
+ {
315
+ role: "status",
316
+ "data-media-unavailable": "true",
317
+ style: { position: "absolute", inset: 0, zIndex: 2, display: "grid", placeContent: "center", color: "#bbb", font: "14px system-ui", background: "#000" },
318
+ children: "Visual unavailable"
319
+ }
320
+ ),
321
+ template ? /* @__PURE__ */ jsx2(SceneBoundary, { scene: range.scene, onFramePresented, children: /* @__PURE__ */ jsxs(Suspense, { fallback: /* @__PURE__ */ jsx2(
322
+ "div",
323
+ {
324
+ "data-template-loading": range.scene.templateId,
325
+ style: { position: "absolute", inset: 0 }
326
+ }
327
+ ), children: [
328
+ /* @__PURE__ */ jsx2(PresentedScene, { notify: onFramePresented }),
329
+ createElement(template.component, {
330
+ variables: {
331
+ ...template.defaults ?? getTemplateDefaults(template.schema),
332
+ ...range.scene.variables,
333
+ ...mediaFailed ? { mediaUrl: "", mediaPoster: "", mediaType: "gradient" } : {},
334
+ ...recoveryTitle ? { title: recoveryTitle } : {}
335
+ },
336
+ style: config.style,
337
+ progress,
338
+ motionProgress,
339
+ beatIntensity: 0,
340
+ width,
341
+ height,
342
+ textArchetype: range.scene.textArchetype ?? config.style.defaultTextArchetype,
343
+ backgroundEffect: range.scene.backgroundEffect ?? config.style.defaultBackgroundEffect,
344
+ safeZone: scaleSafeZone(
345
+ getSafeZone(config.orientation),
346
+ resolveDensity(config.style.density).safeZoneScale
347
+ ),
348
+ sceneDuration: duration,
349
+ isPlaying: playing
350
+ })
351
+ ] }) }, range.scene.id) : /* @__PURE__ */ jsx2(SafeScene, { scene: range.scene, onFramePresented })
352
+ ]
338
353
  }
339
354
  )
340
355
  }
@@ -348,6 +363,7 @@ function VideoFrame({
348
363
  width,
349
364
  height,
350
365
  playing = false,
366
+ preparingNarration = false,
351
367
  mediaAudioMuted = true,
352
368
  mediaAudioVolume = 1,
353
369
  className,
@@ -550,7 +566,9 @@ function VideoFrame({
550
566
  mediaPosition: String(persistentVideoRange.scene.variables.mediaPosition || "center"),
551
567
  backgroundEffect: persistentVideoRange.scene.backgroundEffect ?? config.style.defaultBackgroundEffect,
552
568
  progress: persistentVideoRange.scene.id === active.scene.id ? rawProgress : 0,
569
+ sceneDuration: persistentVideoRange.end - persistentVideoRange.start,
553
570
  isPlaying: persistentVideoRange.scene.id === active.scene.id && playing,
571
+ preparingNarration,
554
572
  muted: mediaAudioMuted || persistentVideoRange.scene.id !== active.scene.id || !playing,
555
573
  volume: mediaAudioVolume,
556
574
  playbackId: persistentVideoRange.scene.id,
@@ -578,11 +596,13 @@ function VideoFrame({
578
596
  kit,
579
597
  config,
580
598
  range: active,
599
+ onMediaError: () => markMediaFailed(sceneReadinessKey(active.scene)),
581
600
  progress,
582
601
  motionProgress,
583
602
  width: canvas.width,
584
603
  height: canvas.height,
585
604
  playing,
605
+ preparingNarration,
586
606
  mediaAudioMuted,
587
607
  mediaAudioVolume,
588
608
  layer: previewingNext ? "outgoing" : "active",
@@ -604,6 +624,7 @@ function VideoFrame({
604
624
  width: canvas.width,
605
625
  height: canvas.height,
606
626
  playing: false,
627
+ preparingNarration: false,
607
628
  mediaAudioMuted,
608
629
  mediaAudioVolume,
609
630
  layer: "incoming",
@@ -622,11 +643,13 @@ function VideoFrame({
622
643
  kit,
623
644
  config,
624
645
  range: active,
646
+ onMediaError: () => markMediaFailed(sceneReadinessKey(active.scene)),
625
647
  progress,
626
648
  motionProgress,
627
649
  width: canvas.width,
628
650
  height: canvas.height,
629
651
  playing,
652
+ preparingNarration,
630
653
  mediaAudioMuted,
631
654
  mediaAudioVolume,
632
655
  layer: "active",
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-5C6HZNHY.js";
4
+ } from "./chunk-QZAZT44G.js";
5
5
 
6
6
  // src/visual-system/scene-templates/cinema-media.tsx
7
7
  import { jsx } from "react/jsx-runtime";
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  MediaScene,
3
3
  MediaSceneTemplate
4
- } from "./chunk-VQH3JTQC.js";
5
- import "./chunk-5C6HZNHY.js";
6
- import "./chunk-77VY4O7A.js";
4
+ } from "./chunk-Z2ZI5G7O.js";
5
+ import "./chunk-QZAZT44G.js";
6
+ import "./chunk-O6FBIB4L.js";
7
7
  import "./chunk-224QNWRA.js";
8
- import "./chunk-TVIU23OM.js";
8
+ import "./chunk-OOBT4X46.js";
9
9
  export {
10
10
  MediaScene,
11
11
  MediaSceneTemplate