@vanillaskyai/video 0.10.5 → 0.10.7

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.
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.7
8
+
9
+ - Reuse the first presented video frame at scene handoffs so a second readiness observation cannot briefly interrupt continuous narration.
10
+
11
+ - Keep an already-speaking paragraph uninterrupted across brief visual handoffs, while genuinely late footage still pauses narration until it can play.
12
+
13
+ - Keep the final chapter visible through answer completion, including recovery from failed footage.
14
+ - Derive recovery titles from authored titles or subject excerpts instead of a generic placeholder.
15
+
16
+ - Let cold footage finish its bounded initial load before treating it as stalled. Require the actual new source to present a frame before narration starts, and keep source changes from cancelling their own native load.
17
+
18
+ ## 0.10.6
19
+
20
+ - Initialize the reusable narration audio element during the existing user gesture so delayed first speech can play on Safari.
21
+
22
+ - Accept valid Pexels search results without descriptive URL slugs. Rank available matching metadata above unknown relevance, without requiring most query words to appear in the slug.
23
+
7
24
  ## 0.10.5
8
25
 
9
26
  - Keep footage visible when native playback reports waiting without another playing event. Observe resumed motion directly and recover to the authored chapter after one second without progress, preserving narration.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  TitleSceneTemplate
3
- } from "./chunk-EWWCQFTI.js";
3
+ } from "./chunk-7AA2JWHZ.js";
4
4
  import "./chunk-4YM2M62S.js";
5
5
  export {
6
6
  TitleSceneTemplate
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VideoFrame
3
- } from "./chunk-VWJBIYBM.js";
3
+ } from "./chunk-24TCMDAA.js";
4
4
  import "./chunk-224QNWRA.js";
5
5
  import "./chunk-OOBT4X46.js";
6
6
  import "./chunk-SPVTJH3F.js";
@@ -33,17 +33,19 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
33
33
  let frame = 0;
34
34
  let callback;
35
35
  let observed;
36
+ let presented;
37
+ const root = marker.current?.closest("[data-video-frame]");
36
38
  const start = performance.now();
37
39
  const finish = (error, actualVideoFrame = false) => {
38
40
  if (!stopped) {
39
41
  stopped = true;
42
+ if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
40
43
  if (error && onFailureRef.current) onFailureRef.current();
41
44
  else report(key, error, actualVideoFrame);
42
45
  }
43
46
  };
44
47
  const check = () => {
45
48
  if (stopped) return;
46
- const root = marker.current?.closest("[data-video-frame]");
47
49
  if (fallback && root?.querySelector("[data-scene-fallback]") && !root.querySelector("[data-template-loading]") && document.fonts?.status !== "loading") {
48
50
  finish();
49
51
  return;
@@ -60,7 +62,11 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
60
62
  if (isVideo) {
61
63
  const persistent = root.querySelector("[data-persistent-video-scene-id]");
62
64
  const video = (persistent?.getAttribute("data-persistent-video-scene-id") === scene.id ? persistent : layer)?.querySelector("video");
63
- if (video && video.getAttribute("src") === mediaUrl && video.readyState >= 2) {
65
+ if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= 2) {
66
+ if (presented === video) {
67
+ finish(void 0, true);
68
+ return;
69
+ }
64
70
  observed = video;
65
71
  if (video.requestVideoFrameCallback) {
66
72
  callback = video.requestVideoFrameCallback(() => finish(void 0, true));
@@ -83,9 +89,20 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
83
89
  }
84
90
  frame = requestAnimationFrame(check);
85
91
  };
92
+ const onPresented = (event) => {
93
+ const video = event.target;
94
+ if (!(video instanceof HTMLVideoElement) || stopped || video.getAttribute("src") !== String(scene.variables.mediaUrl || "") || video.currentSrc !== video.src) return;
95
+ presented = video;
96
+ cancelAnimationFrame(frame);
97
+ if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
98
+ callback = void 0;
99
+ check();
100
+ };
101
+ root?.addEventListener("vanillasky:video-frame-presented", onPresented);
86
102
  check();
87
103
  const timeout = setTimeout(() => finish(new Error("Scene media did not become ready")), 8e3);
88
104
  return () => {
105
+ root?.removeEventListener("vanillasky:video-frame-presented", onPresented);
89
106
  stopped = true;
90
107
  clearTimeout(timeout);
91
108
  cancelAnimationFrame(frame);
@@ -94,39 +111,6 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
94
111
  }, [key, report, scene, playing, fallback]);
95
112
  return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
96
113
  }
97
- function PreparedSceneReadiness({ scene }) {
98
- const marker = useRef(null);
99
- const report = useContext(MountedReadinessContext);
100
- useEffect(() => {
101
- if (!report) return;
102
- let frame = 0;
103
- let stopped = false;
104
- const check = () => {
105
- if (stopped) return;
106
- const root = marker.current?.closest("[data-video-frame]");
107
- const incoming = root?.querySelector("[data-scene-layer='incoming']");
108
- const video = incoming?.getAttribute("data-layer-scene-id") === scene.id ? incoming.querySelector("video") : void 0;
109
- if (video && video.getAttribute("src") === scene.variables.mediaUrl && video.readyState >= 2) {
110
- report(sceneReadinessKey(scene), void 0, false, true);
111
- return;
112
- }
113
- const image = [...root?.querySelectorAll("img[data-video-poster-plane='prepared']") ?? []].find((image2) => image2.getAttribute("src") === scene.variables.mediaPoster);
114
- if (image?.complete && image.naturalWidth > 0) {
115
- void image.decode().then(() => {
116
- if (!stopped && image.isConnected) report(sceneReadinessKey(scene), void 0, false, true);
117
- }).catch(() => void 0);
118
- return;
119
- }
120
- frame = requestAnimationFrame(check);
121
- };
122
- check();
123
- return () => {
124
- stopped = true;
125
- cancelAnimationFrame(frame);
126
- };
127
- }, [scene, report]);
128
- return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
129
- }
130
114
 
131
115
  // src/player/video-frame.tsx
132
116
  import {
@@ -209,7 +193,7 @@ var SceneBoundary = class extends Component {
209
193
  }
210
194
  };
211
195
  var SCENE_TRANSITION_SECONDS = 0.3;
212
- var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-MUGIXE2Q.js").then((module) => ({ default: module.SceneVideoBackdrop })));
196
+ var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-NK2XK3XE.js").then((module) => ({ default: module.SceneVideoBackdrop })));
213
197
  var MEDIA_PREROLL_SECONDS = 1.2;
214
198
  var CONTIGUITY_ULP_FACTOR = 4;
215
199
  var subscribeToDecoderPolicy = () => () => {
@@ -447,7 +431,6 @@ function VideoFrame({
447
431
  const persistentVideoKey = persistentVideoRange ? `${persistentVideoRange.scene.id}\0${String(persistentVideoRange.scene.variables.mediaUrl || "")}` : void 0;
448
432
  const firstVideoRange = timeline.find(sceneHasVideoBackdrop);
449
433
  const posterPreparationRange = activeUsesPersistentVideo ? contiguousNext && sceneHasVideoBackdrop(contiguousNext) ? contiguousNext : activeIndex === timeline.length - 1 && firstVideoRange?.scene.id !== active.scene.id ? firstVideoRange : void 0 : void 0;
450
- const preparedReadinessRange = posterPreparationRange ?? (contiguousNext && sceneHasVideoBackdrop(contiguousNext) ? contiguousNext : void 0);
451
434
  const preparedPoster = posterPreparationRange && String(
452
435
  posterPreparationRange.scene.variables.mediaPoster || ""
453
436
  ) ? {
@@ -471,7 +454,9 @@ function VideoFrame({
471
454
  const blendProgress = previewingNext && blendDuration > 0 ? Math.round(clamp01((time - blendStart) / blendDuration) * 1e6) / 1e6 : 0;
472
455
  const progress = rawProgress;
473
456
  const isFinalScene = activeIndex === timeline.length - 1;
474
- const motionProgress = isFinalScene && activeTiming ? Math.min(rawProgress, activeTiming.holdProgress) : rawProgress;
457
+ const presentsChapter = active.scene.templateId === "chapterTitle" || active.scene.templateId === "cinemaMedia" && (activeMediaFailed || !String(active.scene.variables.mediaUrl || "").trim());
458
+ const finalHold = presentsChapter ? 0.76 : activeTiming?.holdProgress;
459
+ const motionProgress = isFinalScene && finalHold !== void 0 ? Math.min(rawProgress, finalHold) : rawProgress;
475
460
  const canvas = getDimensions(config.orientation);
476
461
  const scale = Math.min(width / canvas.width, height / canvas.height);
477
462
  const canvasLeft = (width - canvas.width * scale) / 2;
@@ -507,7 +492,6 @@ function VideoFrame({
507
492
  onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene)) : void 0
508
493
  }
509
494
  ),
510
- preparedReadinessRange && /* @__PURE__ */ jsx2(PreparedSceneReadiness, { scene: preparedReadinessRange.scene }),
511
495
  /* @__PURE__ */ jsxs(
512
496
  "div",
513
497
  {
@@ -5,10 +5,11 @@ import {
5
5
 
6
6
  // src/visual-system/scene-templates/chapter-title.tsx
7
7
  import { jsx } from "react/jsx-runtime";
8
- function TitleScene({ variables, width, height, progress }) {
8
+ function TitleScene({ variables, width, height, progress, motionProgress }) {
9
9
  const title = String(variables.title ?? "A different perspective");
10
10
  const unit = Math.min(width, height);
11
- const opacity = fade(progress / 0.22) * (1 - fade((progress - 0.76) / 0.24));
11
+ const presentation = motionProgress ?? progress;
12
+ const opacity = fade(presentation / 0.22) * (1 - fade((presentation - 0.76) / 0.24));
12
13
  return /* @__PURE__ */ jsx("div", { "data-template": "title", "data-title-treatment": "quiet-fade", style: { position: "absolute", inset: 0, background: "#000", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsx("div", { "data-title-composition": "centered", style: { width: "76%", textAlign: "center", opacity, fontFamily: editorialFont, fontWeight: 500, fontSize: unit * 0.068, lineHeight: 1.18, letterSpacing: "-.025em", textWrap: "balance", overflowWrap: "anywhere" }, children: title }) });
13
14
  }
14
15
  var TitleSceneTemplate = TitleScene;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-MGCD3ZJ3.js";
4
+ } from "./chunk-X4RGAEL5.js";
5
5
  import {
6
6
  editorialFont
7
7
  } from "./chunk-4YM2M62S.js";
@@ -255,6 +255,7 @@ var SceneVideoBackdrop = ({
255
255
  const [waitingKey, setWaitingKey] = useState();
256
256
  const [exhaustedKey, setExhaustedKey] = useState();
257
257
  const videoRef = useRef(null);
258
+ const presentedVideoUrl = useRef(void 0);
258
259
  const startedVideoUrl = useRef(void 0);
259
260
  const startedPlaybackId = useRef(void 0);
260
261
  const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
@@ -274,6 +275,8 @@ var SceneVideoBackdrop = ({
274
275
  }
275
276
  const video = videoRef.current;
276
277
  if (!video) return;
278
+ const expectedSource = video.getAttribute("src") === mediaUrl ? video.src : void 0;
279
+ let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;
277
280
  let previousTime = video.currentTime;
278
281
  let forwardFrames = 0;
279
282
  let stopped = false;
@@ -281,8 +284,14 @@ var SceneVideoBackdrop = ({
281
284
  let poll;
282
285
  const observe = (_now, metadata) => {
283
286
  if (stopped) return;
287
+ const currentSource = video.currentSrc === expectedSource;
288
+ if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {
289
+ awaitingFirstFrame = false;
290
+ clearTimeout(deadline);
291
+ deadline = setTimeout(fail, 1e3);
292
+ }
284
293
  const time = metadata?.mediaTime ?? video.currentTime;
285
- if (video.seeking || time < previousTime) forwardFrames = 0;
294
+ if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;
286
295
  else if (time > previousTime + 1e-3) forwardFrames++;
287
296
  previousTime = time;
288
297
  if (forwardFrames >= 2) {
@@ -294,12 +303,13 @@ var SceneVideoBackdrop = ({
294
303
  if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);
295
304
  else poll = setTimeout(observe, 50);
296
305
  };
297
- const deadline = setTimeout(() => {
306
+ const fail = () => {
298
307
  if (!stopped) {
299
308
  stopped = true;
300
309
  unavailable();
301
310
  }
302
- }, 1e3);
311
+ };
312
+ let deadline = setTimeout(fail, awaitingFirstFrame ? 8e3 : 1e3);
303
313
  observe();
304
314
  return () => {
305
315
  stopped = true;
@@ -330,6 +340,10 @@ var SceneVideoBackdrop = ({
330
340
  video.setAttribute("src", mediaUrl);
331
341
  video.load();
332
342
  }
343
+ }, [mediaUrl]);
344
+ useEffect(() => {
345
+ const video = videoRef.current;
346
+ if (!video) return;
333
347
  return () => {
334
348
  video.pause();
335
349
  video.removeAttribute("src");
@@ -337,7 +351,7 @@ var SceneVideoBackdrop = ({
337
351
  startedVideoUrl.current = void 0;
338
352
  startedPlaybackId.current = void 0;
339
353
  };
340
- }, [mediaUrl]);
354
+ }, []);
341
355
  useEffect(() => {
342
356
  const video = videoRef.current;
343
357
  if (video) video.volume = resolvedVolume;
@@ -450,7 +464,9 @@ var SceneVideoBackdrop = ({
450
464
  onLoadedData: (event) => {
451
465
  const video = event.currentTarget;
452
466
  const markPresented = () => {
453
- if (!video.isConnected) return;
467
+ if (!video.isConnected || presentationRef.current.key !== videoPresentationKey || video.getAttribute("src") !== mediaUrl || video.currentSrc !== video.src) return;
468
+ presentedVideoUrl.current = mediaUrl;
469
+ video.dispatchEvent(new Event("vanillasky:video-frame-presented", { bubbles: true }));
454
470
  onReady?.();
455
471
  if (!retainPoster) setDecodedVideoUrl(mediaUrl);
456
472
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-MGCD3ZJ3.js";
4
+ } from "./chunk-X4RGAEL5.js";
5
5
 
6
6
  // src/visual-system/scene-templates/cinema-media.tsx
7
7
  import { jsx } from "react/jsx-runtime";
@@ -3,7 +3,7 @@ import {
3
3
  SceneVideoBackdrop,
4
4
  getBackgroundTransform,
5
5
  resolveMediaPosition
6
- } from "./chunk-DD4ZSYKG.js";
6
+ } from "./chunk-OBRKB7PL.js";
7
7
  import {
8
8
  resolveMediaType
9
9
  } from "./chunk-224QNWRA.js";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  MediaScene,
3
3
  MediaSceneTemplate
4
- } from "./chunk-XBIKTSFY.js";
5
- import "./chunk-MGCD3ZJ3.js";
6
- import "./chunk-DD4ZSYKG.js";
4
+ } from "./chunk-PMJNBR4F.js";
5
+ import "./chunk-X4RGAEL5.js";
6
+ import "./chunk-OBRKB7PL.js";
7
7
  import "./chunk-224QNWRA.js";
8
8
  import "./chunk-OOBT4X46.js";
9
9
  export {
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-JNZRVC45.js";
4
- import "./chunk-MGCD3ZJ3.js";
5
- import "./chunk-DD4ZSYKG.js";
3
+ } from "./chunk-MKMCX3AF.js";
4
+ import "./chunk-X4RGAEL5.js";
5
+ import "./chunk-OBRKB7PL.js";
6
6
  import {
7
7
  editorialLabel,
8
8
  fade
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-JNZRVC45.js";
4
- import "./chunk-MGCD3ZJ3.js";
5
- import "./chunk-DD4ZSYKG.js";
3
+ } from "./chunk-MKMCX3AF.js";
4
+ import "./chunk-X4RGAEL5.js";
5
+ import "./chunk-OBRKB7PL.js";
6
6
  import {
7
7
  fade
8
8
  } from "./chunk-4YM2M62S.js";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-JNZRVC45.js";
4
- import "./chunk-MGCD3ZJ3.js";
5
- import "./chunk-DD4ZSYKG.js";
3
+ } from "./chunk-MKMCX3AF.js";
4
+ import "./chunk-X4RGAEL5.js";
5
+ import "./chunk-OBRKB7PL.js";
6
6
  import {
7
7
  editorialLabel,
8
8
  fade
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  MediaScene
3
- } from "./chunk-XBIKTSFY.js";
4
- import "./chunk-MGCD3ZJ3.js";
3
+ } from "./chunk-PMJNBR4F.js";
4
+ import "./chunk-X4RGAEL5.js";
5
5
  import {
6
6
  spring
7
- } from "./chunk-DD4ZSYKG.js";
7
+ } from "./chunk-OBRKB7PL.js";
8
8
  import {
9
9
  editorialFont
10
10
  } from "./chunk-4YM2M62S.js";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-JNZRVC45.js";
4
- import "./chunk-MGCD3ZJ3.js";
5
- import "./chunk-DD4ZSYKG.js";
3
+ } from "./chunk-MKMCX3AF.js";
4
+ import "./chunk-X4RGAEL5.js";
5
+ import "./chunk-OBRKB7PL.js";
6
6
  import {
7
7
  fade
8
8
  } from "./chunk-4YM2M62S.js";
package/dist/react.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  TitleSceneTemplate
3
- } from "./chunk-EWWCQFTI.js";
3
+ } from "./chunk-7AA2JWHZ.js";
4
4
  import "./chunk-4YM2M62S.js";
5
5
  import {
6
6
  getSceneDuration,
@@ -40,7 +40,7 @@ import {
40
40
  VideoFrame,
41
41
  getDimensions,
42
42
  sceneReadinessKey
43
- } from "./chunk-VWJBIYBM.js";
43
+ } from "./chunk-24TCMDAA.js";
44
44
  import "./chunk-224QNWRA.js";
45
45
  import "./chunk-OOBT4X46.js";
46
46
  import {
@@ -94,13 +94,13 @@ import { createElement } from "react";
94
94
 
95
95
  // src/visual-system/catalog/builtin-loaders.generated.ts
96
96
  var GENERATED_BUILTIN_TEMPLATE_LOADERS = {
97
- "cinemaMedia": () => import("./cinema-media-AYKCBXRI.js").then((module) => ({ default: module.MediaSceneTemplate })),
98
- "chapterTitle": () => import("./chapter-title-YRLIQY3V.js").then((module) => ({ default: module.TitleSceneTemplate })),
99
- "editorialTimeline": () => import("./editorial-timeline-DUW7C75H.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
- "mobileMessage": () => import("./mobile-message-KBLF3WUR.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
- "comparison": () => import("./comparison-74B2XEH4.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
- "quote": () => import("./quote-QCGR2HOU.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
- "keyFigure": () => import("./key-figure-OEWIMPGS.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
97
+ "cinemaMedia": () => import("./cinema-media-BVQFZL3A.js").then((module) => ({ default: module.MediaSceneTemplate })),
98
+ "chapterTitle": () => import("./chapter-title-2JVDU62E.js").then((module) => ({ default: module.TitleSceneTemplate })),
99
+ "editorialTimeline": () => import("./editorial-timeline-JADHHNEV.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
+ "mobileMessage": () => import("./mobile-message-GADX632R.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
+ "comparison": () => import("./comparison-T27C7FSV.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
+ "quote": () => import("./quote-FRCEC34C.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
+ "keyFigure": () => import("./key-figure-TZQWPFMI.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
104
104
  };
105
105
 
106
106
  // src/visual-system/catalog/builtin-player.generated.ts
@@ -534,7 +534,6 @@ function usePlaybackClock({
534
534
  loopRef,
535
535
  sceneIndexRef,
536
536
  visualReadyRef,
537
- posterBridgeKeysRef,
538
537
  callbacksRef,
539
538
  setCurrentTime,
540
539
  setIsPlaying
@@ -545,6 +544,10 @@ function usePlaybackClock({
545
544
  let onsetWaitSeconds = 0;
546
545
  let clockWaitSeconds = 0;
547
546
  let lastNarrationTime;
547
+ let committedTime = timeRef.current;
548
+ let groupHandoff;
549
+ let requestId = stateRef.current.requestId;
550
+ let runId = stateRef.current.runId;
548
551
  const failNarration = (error, state) => {
549
552
  setIsPlaying(false);
550
553
  try {
@@ -565,6 +568,12 @@ function usePlaybackClock({
565
568
  const tick = (now) => {
566
569
  const current = stateRef.current;
567
570
  const config = current.config;
571
+ const externallySeeked = timeRef.current !== committedTime;
572
+ const replaced = current.requestId !== requestId || current.runId !== runId;
573
+ requestId = current.requestId;
574
+ runId = current.runId;
575
+ if (externallySeeked || replaced) groupHandoff = void 0;
576
+ let deferGroupStall = false;
568
577
  const elapsed = Math.max(0, (now - previous) / 1e3);
569
578
  let narrationReady = true;
570
579
  try {
@@ -599,6 +608,7 @@ function usePlaybackClock({
599
608
  return;
600
609
  }
601
610
  clockWaitSeconds = narrationTime !== void 0 && narrationTime === lastNarrationTime && narrationReady && !stalled ? clockWaitSeconds + elapsed : 0;
611
+ const audioMovedBackwards = narrationTime !== void 0 && lastNarrationTime !== void 0 && narrationTime < lastNarrationTime;
602
612
  lastNarrationTime = narrationTime;
603
613
  if (clockWaitSeconds >= 8) {
604
614
  failNarration(new Error("Narration audio clock did not advance within eight seconds"), current);
@@ -613,8 +623,18 @@ function usePlaybackClock({
613
623
  nextTime = Math.min(raw, duration2);
614
624
  }
615
625
  const target = ranges.find((range) => nextTime >= range.start && nextTime < range.end) ?? ranges.at(-1);
616
- const waitingForVisual = Boolean(target && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(target.scene) && !posterBridgeKeysRef?.current.has(sceneReadinessKey(target.scene)));
617
- if (waitingForVisual && target) nextTime = target.start;
626
+ const waitingForVisual = Boolean(target && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(target.scene));
627
+ if (waitingForVisual && target) {
628
+ nextTime = target.start;
629
+ const fromGroup = cued?.scene.narrationGroup;
630
+ const toGroup = target.scene.narrationGroup;
631
+ const continuesParagraph = !externallySeeked && !replaced && !audioMovedBackwards && narrationReady && narrationTime !== void 0 && fromGroup && toGroup && fromGroup.id === toGroup.id && fromGroup.text === toGroup.text && target === ranges[sceneIndexRef.current + 1] && narrationTime > fromGroup.offsetSeconds + 0.04;
632
+ if (continuesParagraph) {
633
+ const key = `${sceneReadinessKey(cued.scene)}\0${sceneReadinessKey(target.scene)}\0${fromGroup.id}\0${fromGroup.text}`;
634
+ if (groupHandoff?.key !== key) groupHandoff = { key, startedAt: now };
635
+ deferGroupStall = !stalled && now - groupHandoff.startedAt < 200;
636
+ } else groupHandoff = void 0;
637
+ } else groupHandoff = void 0;
618
638
  if (nextTime !== timeRef.current) {
619
639
  timeRef.current = nextTime;
620
640
  setCurrentTime(nextTime);
@@ -645,7 +665,8 @@ function usePlaybackClock({
645
665
  }
646
666
  const duration = current.config ? getVideoDuration(current.config) : 0;
647
667
  const active = current.config ? resolveVideoTimeline(current.config).find((range) => timeRef.current >= range.start && timeRef.current < range.end) : void 0;
648
- 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);
668
+ reportStall(Boolean(active && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(active.scene) && !deferGroupStall) || !settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
669
+ committedTime = timeRef.current;
649
670
  if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
650
671
  else setIsPlaying(false);
651
672
  };
@@ -732,13 +753,8 @@ function VideoPlayerRuntime({
732
753
  const loopRef = useRef(loop);
733
754
  const sceneIndexRef = useRef(-1);
734
755
  const mediaFrameReportedRef = useRef(false);
735
- const posterBridgeKeysRef = useRef(/* @__PURE__ */ new Set());
736
756
  const visualReadyRef = useRef(void 0);
737
- const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false, posterBridge = false) => {
738
- if (posterBridge) {
739
- posterBridgeKeysRef.current.add(key);
740
- return;
741
- }
757
+ const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false) => {
742
758
  if (error) {
743
759
  setIsPlaying(false);
744
760
  callbacksRef.current.onError?.(error, stateRef.current);
@@ -798,7 +814,6 @@ function VideoPlayerRuntime({
798
814
  setActiveStream(stream);
799
815
  setActiveSavedVideo(video);
800
816
  mediaFrameReportedRef.current = false;
801
- posterBridgeKeysRef.current.clear();
802
817
  sceneIndexRef.current = -1;
803
818
  setReplacementPending(stream != null);
804
819
  setState(video ? savedVideoState(video) : createVideoState());
@@ -917,7 +932,6 @@ function VideoPlayerRuntime({
917
932
  loopRef,
918
933
  sceneIndexRef,
919
934
  visualReadyRef,
920
- posterBridgeKeysRef,
921
935
  callbacksRef,
922
936
  setCurrentTime,
923
937
  setIsPlaying
@@ -1537,6 +1551,7 @@ async function prepareSceneMedia(variables, signal) {
1537
1551
  var DEFAULT_MAX_CACHED_LINES = 60;
1538
1552
  var SPEECH_PREPARATION_TIMEOUT_MS = 3e3;
1539
1553
  var FALLBACK_BITS_PER_SECOND = 128e3;
1554
+ var ACTIVATION_AUDIO = "data:audio/wav;base64,UklGRrQBAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YZABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1540
1555
  var sharedContext;
1541
1556
  function actionEndpoint(endpoint, action) {
1542
1557
  const value = String(endpoint);
@@ -1689,6 +1704,14 @@ function createVideoChatVoice(options = {}) {
1689
1704
  },
1690
1705
  resume() {
1691
1706
  held = false;
1707
+ if (!disposed && !silent && !generatedElement && globalThis.navigator?.userActivation?.isActive) {
1708
+ const element = generatedElement = new Audio();
1709
+ element.src = ACTIVATION_AUDIO;
1710
+ try {
1711
+ void element.play().catch(() => void 0);
1712
+ } catch {
1713
+ }
1714
+ }
1692
1715
  if (sounding) {
1693
1716
  const fail = playbackFailure;
1694
1717
  void sounding.play().catch(() => {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SceneVideoBackdrop
3
- } from "./chunk-DD4ZSYKG.js";
3
+ } from "./chunk-OBRKB7PL.js";
4
4
  import "./chunk-OOBT4X46.js";
5
5
  export {
6
6
  SceneVideoBackdrop
package/dist/server.js CHANGED
@@ -895,14 +895,24 @@ var object = (value) => value && typeof value === "object" && !Array.isArray(val
895
895
  function text(value, maximum) {
896
896
  return typeof value === "string" && value.trim().length <= maximum ? value.trim() : "";
897
897
  }
898
- function readShot(value, clipDurationSec) {
898
+ function chapterSubject(subject) {
899
+ const normalized = subject.replace(/\s+/gu, " ");
900
+ if (normalized.length <= 65) return normalized;
901
+ const prefix = normalized.slice(0, 65);
902
+ const boundary = prefix.lastIndexOf(" ");
903
+ return boundary > 0 ? prefix.slice(0, boundary) : "";
904
+ }
905
+ function readShot(value, clipDurationSec, answerSubject = "") {
899
906
  const item = object(value);
900
907
  const narration = text(item?.narration, 2e3);
901
908
  if (!narration) throw new Error("Chat shot requires bounded authored narration");
909
+ const subject = text(item?.subject, 80);
910
+ const title = text(item?.title, 65) || chapterSubject(subject) || chapterSubject(answerSubject);
911
+ if (!title) throw new Error("Chat shot requires an authored chapter title or subject");
902
912
  return {
903
913
  narration,
904
- title: text(item?.title, 65) || text(item?.subject, 65) || "The next step",
905
- subject: text(item?.subject, 80),
914
+ title,
915
+ subject,
906
916
  action: text(item?.action, 600),
907
917
  durationSec: typeof item?.durationSec === "number" && Number.isFinite(item.durationSec) ? Math.min(clipDurationSec, Math.max(2, item.durationSec)) : clipDurationSec,
908
918
  continuity: item?.continuity === "continue" ? "continue" : "cut"
@@ -973,7 +983,7 @@ function createChatShotPlanner(options) {
973
983
  brief = { opening: text(part.opening, 300), subject: text(part.subject, 80), visualDirection: text(part.visualDirection, 600), development: text(part.development, 2e3) };
974
984
  if (part.ending) {
975
985
  try {
976
- brief.ending = readShot(part.ending, clipDurationSec);
986
+ brief.ending = readShot(part.ending, clipDurationSec, brief.subject);
977
987
  } catch (cause) {
978
988
  reject(cause);
979
989
  }
@@ -983,7 +993,7 @@ function createChatShotPlanner(options) {
983
993
  }
984
994
  if (part?.type !== "shot") throw new Error("Chat plan requires an answer brief followed by shots");
985
995
  if (!brief) throw new Error("Chat shot arrived before its answer brief");
986
- const shot = readShot(part, clipDurationSec);
996
+ const shot = readShot(part, clipDurationSec, brief.subject);
987
997
  if (shot.narration === brief.ending?.narration) return;
988
998
  if (firstBody && !continueAfterOpening(shot.narration, [options.openingLine ?? brief.opening])) return;
989
999
  const budget = (context.request.input.maxDurationSec ?? 40) - (brief.ending?.durationSec ?? clipDurationSec);
@@ -2,7 +2,7 @@
2
2
 
3
3
  Run `npm ci --no-audit`, then `npm run dev:chat`. The localhost HMR surface renders the actual SDK `VideoChat`, templates and server handler from source. It never loads provider credentials. Select an intent and fault condition in the development toolbar; use the chat's normal media-mode controls. The prompt box remains the real product UI.
4
4
 
5
- Offline answers are deterministic for explanation, story, comedy, imagination, practical steps and golf. They use local waterfall footage and a recorded timing cue, **not narration that matches the displayed script**. This harness is for loading, playback, controls and recovery. It cannot prove generated answer quality. Conditions cover ready, delayed footage, missing media, decode failure, speech failure, exhausted video allowance and request throttling. Browser speech may be used in the speech-failure condition.
5
+ Offline answers are deterministic for explanation, story, comedy, imagination, practical steps and golf. They use local waterfall footage and locally synthesized speech matching each displayed opening, body and ending. This harness lets you hear complete narration while checking loading, timing, controls and recovery without AI credits. The waterfall remains generic fixture footage; it cannot prove generated answer quality or visual relevance. See [spoken fixture provenance](https://github.com/VanillaSkyAi/video/blob/main/dev/chat/speech/README.md) for transcripts and regeneration commands. Conditions cover ready, delayed footage, missing media, decode failure, speech failure, exhausted video allowance and request throttling. Browser speech may be used in the speech-failure condition.
6
6
 
7
7
  The toolbar labels source and fixture identity. It separates the first body surface from the first decoded moving-footage frame; the former is a renderer paint opportunity and can precede decode. Its bounded safe phase log records browser request/stream arrival phases, speech response completion, first speech, footage and buffer pauses. No prompts, narration, scene IDs or provider bodies are retained. Media start/end/skip reasons come from the handler’s separate host-only `onDiagnostic` callback, shown in the local terminal for offline fixtures; live hosts own that callback themselves. Stream arrival is not the server’s exact authorship timestamp. Local fixtures never fetch external footage or invoke a paid model. Mode boundaries, provider deadlines and host admission remain covered by their dedicated server and host suites.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -40,7 +40,7 @@
40
40
  "path": "src/visual-system/scene-templates/scene-video-backdrop.tsx",
41
41
  "type": "registry:lib",
42
42
  "target": "vanillasky/scene-templates/scene-video-backdrop.tsx",
43
- "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n retainPoster?: boolean;\n persistent?: boolean;\n preparedPoster?: {\n presentationKey: string;\n mediaPoster: string;\n mediaPosition: string;\n backgroundEffect?: string;\n /** Existing global transition progress. On decoder-constrained Safari,\n * this fades the decoded incoming still above the outgoing video before\n * the single video element changes source. */\n opacity?: number;\n };\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n retainPoster = false,\n persistent = false,\n preparedPoster,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = () => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.();\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const time = metadata?.mediaTime ?? video.currentTime;\n if (video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const deadline = setTimeout(() => {\n if (!stopped) { stopped = true; unavailable(); }\n }, 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(unavailable);\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(unavailable);\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(unavailable);\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n zIndex: persistent ? 1 : undefined,\n };\n const preparedPosition = preparedPoster\n ? resolveMediaPosition(preparedPoster.mediaPosition)\n : resolvedPosition;\n const preparedTransform = getBackgroundTransform(preparedPoster?.backgroundEffect, 0, 0);\n const posterPlanes = [\n ...(persistent && mediaPoster ? [{\n presentationKey: videoPresentationKey,\n mediaPoster,\n mediaPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n opacity: 1,\n zIndex: 0,\n role: \"current\",\n }] : []),\n ...(preparedPoster && preparedPoster.presentationKey !== videoPresentationKey ? [{\n presentationKey: preparedPoster.presentationKey,\n mediaPoster: preparedPoster.mediaPoster,\n mediaPosition: preparedPosition,\n transform: preparedTransform.transform,\n transformOrigin: preparedTransform.transformOrigin,\n opacity: preparedPoster.opacity ?? 0,\n zIndex: 2,\n role: \"prepared\",\n }] : []),\n ];\n\n return (\n <>\n {posterPlanes.map((posterPlane) => (\n <img\n key={posterPlane.presentationKey}\n src={posterPlane.mediaPoster}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-video-poster-plane={posterPlane.role}\n data-video-poster-visible={posterPlane.opacity > 0 ? \"true\" : \"false\"}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: posterPlane.mediaPosition,\n transform: posterPlane.transform,\n transformOrigin: posterPlane.transformOrigin,\n zIndex: posterPlane.zIndex,\n opacity: posterPlane.opacity,\n pointerEvents: \"none\",\n }}\n />\n ))}\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={retainPoster || decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onLoadedData={(event) => {\n const video = event.currentTarget;\n const markPresented = () => {\n if (!video.isConnected) return;\n onReady?.();\n if (!retainPoster) setDecodedVideoUrl(mediaUrl);\n };\n if (video.requestVideoFrameCallback) {\n video.requestVideoFrameCallback(markPresented);\n return;\n }\n markPresented();\n }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop={persistent ? \"persistent\" : \"scene\"}\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
43
+ "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n retainPoster?: boolean;\n persistent?: boolean;\n preparedPoster?: {\n presentationKey: string;\n mediaPoster: string;\n mediaPosition: string;\n backgroundEffect?: string;\n /** Existing global transition progress. On decoder-constrained Safari,\n * this fades the decoded incoming still above the outgoing video before\n * the single video element changes source. */\n opacity?: number;\n };\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n retainPoster = false,\n persistent = false,\n preparedPoster,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const presentedVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = () => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.();\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {\n awaitingFirstFrame = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // Only a source that has presented a frame can be judged as stalled motion.\n let deadline = setTimeout(fail, awaitingFirstFrame ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(unavailable);\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(unavailable);\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(unavailable);\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n zIndex: persistent ? 1 : undefined,\n };\n const preparedPosition = preparedPoster\n ? resolveMediaPosition(preparedPoster.mediaPosition)\n : resolvedPosition;\n const preparedTransform = getBackgroundTransform(preparedPoster?.backgroundEffect, 0, 0);\n const posterPlanes = [\n ...(persistent && mediaPoster ? [{\n presentationKey: videoPresentationKey,\n mediaPoster,\n mediaPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n opacity: 1,\n zIndex: 0,\n role: \"current\",\n }] : []),\n ...(preparedPoster && preparedPoster.presentationKey !== videoPresentationKey ? [{\n presentationKey: preparedPoster.presentationKey,\n mediaPoster: preparedPoster.mediaPoster,\n mediaPosition: preparedPosition,\n transform: preparedTransform.transform,\n transformOrigin: preparedTransform.transformOrigin,\n opacity: preparedPoster.opacity ?? 0,\n zIndex: 2,\n role: \"prepared\",\n }] : []),\n ];\n\n return (\n <>\n {posterPlanes.map((posterPlane) => (\n <img\n key={posterPlane.presentationKey}\n src={posterPlane.mediaPoster}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-video-poster-plane={posterPlane.role}\n data-video-poster-visible={posterPlane.opacity > 0 ? \"true\" : \"false\"}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: posterPlane.mediaPosition,\n transform: posterPlane.transform,\n transformOrigin: posterPlane.transformOrigin,\n zIndex: posterPlane.zIndex,\n opacity: posterPlane.opacity,\n pointerEvents: \"none\",\n }}\n />\n ))}\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={retainPoster || decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onLoadedData={(event) => {\n const video = event.currentTarget;\n const markPresented = () => {\n if (!video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return;\n presentedVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReady?.();\n if (!retainPoster) setDecodedVideoUrl(mediaUrl);\n };\n if (video.requestVideoFrameCallback) {\n video.requestVideoFrameCallback(markPresented);\n return;\n }\n markPresented();\n }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop={persistent ? \"persistent\" : \"scene\"}\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
44
44
  },
45
45
  {
46
46
  "path": "src/visual-system/scene-templates/color-utils.ts",
@@ -16,7 +16,7 @@
16
16
  "path": "src/visual-system/scene-templates/chapter-title.tsx",
17
17
  "type": "registry:component",
18
18
  "target": "vanillasky/scene-templates/chapter-title.tsx",
19
- "content": "import type { SceneTemplateProps } from './types';\n\nimport {editorialFont,fade as ease} from './editorial-typography';\n\n/** A quiet chapter beat: fade in, read, fade to black. */\nfunction TitleScene({ variables, width, height, progress }: SceneTemplateProps) {\n const title = String(variables.title ?? 'A different perspective');\n const unit = Math.min(width, height);\n const opacity = ease(progress / .22) * (1 - ease((progress - .76) / .24));\n return <div data-template=\"title\" data-title-treatment=\"quiet-fade\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',display:'flex',alignItems:'center',justifyContent:'center'}}>\n <div data-title-composition=\"centered\" style={{width:'76%',textAlign:'center',opacity,fontFamily:editorialFont,fontWeight:500,fontSize:unit * .068,lineHeight:1.18,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{title}</div>\n </div>;\n}\n\nexport const TitleSceneTemplate = TitleScene;\n"
19
+ "content": "import type { SceneTemplateProps } from './types';\n\nimport {editorialFont,fade as ease} from './editorial-typography';\n\n/** A quiet chapter beat: fade in, read, fade to black. */\nfunction TitleScene({ variables, width, height, progress, motionProgress }: SceneTemplateProps) {\n const title = String(variables.title ?? 'A different perspective');\n const unit = Math.min(width, height);\n const presentation = motionProgress ?? progress;\n const opacity = ease(presentation / .22) * (1 - ease((presentation - .76) / .24));\n return <div data-template=\"title\" data-title-treatment=\"quiet-fade\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',display:'flex',alignItems:'center',justifyContent:'center'}}>\n <div data-title-composition=\"centered\" style={{width:'76%',textAlign:'center',opacity,fontFamily:editorialFont,fontWeight:500,fontSize:unit * .068,lineHeight:1.18,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{title}</div>\n </div>;\n}\n\nexport const TitleSceneTemplate = TitleScene;\n"
20
20
  },
21
21
  {
22
22
  "path": "src/visual-system/scene-templates/types.ts",
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.5",
12
+ "@vanillaskyai/video": "0.10.7",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -8,6 +8,8 @@ interface StockVideo {
8
8
  interface PexelsVideo {
9
9
  url?: string;
10
10
  image?: string;
11
+ title?: unknown;
12
+ tags?: unknown;
11
13
  video_files?: { link?: string; width?: number; height?: number; file_type?: string }[];
12
14
  }
13
15
  const cache = new Map<string, { expires: number; media: StockVideo | null }>();
@@ -21,7 +23,7 @@ function pexelsUrl(value: unknown): value is string {
21
23
  } catch { return false; }
22
24
  }
23
25
 
24
- /** Full catalog search. Metadata establishes subject relevance, not factual proof.
26
+ /** Full catalog search. Available metadata ranks subject relevance, not factual proof.
25
27
  * Applications using this adapter must display a prominent link to Pexels.
26
28
  * https://www.pexels.com/api/documentation/#guidelines
27
29
  */
@@ -41,13 +43,18 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
41
43
  if (!response.ok) return null;
42
44
  const result = await response.json() as { videos?: PexelsVideo[] };
43
45
  signal.throwIfAborted();
44
- let selected: StockVideo | null = null, bestScore = 0;
46
+ let selected: StockVideo | null = null, bestScore = -1;
45
47
  for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
46
48
  if (!pexelsUrl(video.url)) continue;
47
- const subject = words(new URL(video.url).pathname);
49
+ const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
50
+ const title = typeof video.title === "string" ? video.title : "";
51
+ const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
52
+ const subject = words(`${slug} ${title} ${tags}`).filter(token => !/^\d+$/.test(token));
48
53
  const matches = tokens.filter(token => subject.includes(token)).length;
49
- // Require a majority of the literal query, never accept search rank alone.
50
- if (matches < Math.ceil(tokens.length * 0.6)) continue;
54
+ // The documented Video resource can have only a numeric page URL and no
55
+ // editorial metadata. Preserve provider search order for unknown relevance;
56
+ // positive overlap ranks above it, while explicitly unrelated copy is skipped.
57
+ if (subject.length > 0 && matches === 0) continue;
51
58
  const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
52
59
  file.file_type === "video/mp4" && pexelsUrl(file.link)
53
60
  && Number.isFinite(file.width) && Number.isFinite(file.height)