@vanillaskyai/video 0.10.4 → 0.10.6

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,16 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.10.6
8
+
9
+ - Initialize the reusable narration audio element during the existing user gesture so delayed first speech can play on Safari.
10
+
11
+ - 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.
12
+
13
+ ## 0.10.5
14
+
15
+ - 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.
16
+
7
17
  ## 0.10.4
8
18
 
9
19
  - Show the canonical chapter immediately after submitting a prompt; remove the separate gradient loading placeholder and opening stock requests.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VideoFrame
3
- } from "./chunk-WZFLEPLM.js";
3
+ } from "./chunk-VWJBIYBM.js";
4
4
  import "./chunk-224QNWRA.js";
5
5
  import "./chunk-OOBT4X46.js";
6
6
  import "./chunk-SPVTJH3F.js";
@@ -267,6 +267,47 @@ var SceneVideoBackdrop = ({
267
267
  reportMediaFailure?.();
268
268
  }
269
269
  };
270
+ useEffect(() => {
271
+ if (!isPlaying || waitingKey !== videoPresentationKey) {
272
+ if (waitingKey) setWaitingKey(void 0);
273
+ return;
274
+ }
275
+ const video = videoRef.current;
276
+ if (!video) return;
277
+ let previousTime = video.currentTime;
278
+ let forwardFrames = 0;
279
+ let stopped = false;
280
+ let frame;
281
+ let poll;
282
+ const observe = (_now, metadata) => {
283
+ if (stopped) return;
284
+ const time = metadata?.mediaTime ?? video.currentTime;
285
+ if (video.seeking || time < previousTime) forwardFrames = 0;
286
+ else if (time > previousTime + 1e-3) forwardFrames++;
287
+ previousTime = time;
288
+ if (forwardFrames >= 2) {
289
+ stopped = true;
290
+ clearTimeout(deadline);
291
+ setWaitingKey(void 0);
292
+ return;
293
+ }
294
+ if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);
295
+ else poll = setTimeout(observe, 50);
296
+ };
297
+ const deadline = setTimeout(() => {
298
+ if (!stopped) {
299
+ stopped = true;
300
+ unavailable();
301
+ }
302
+ }, 1e3);
303
+ observe();
304
+ return () => {
305
+ stopped = true;
306
+ clearTimeout(deadline);
307
+ clearTimeout(poll);
308
+ if (frame !== void 0) video.cancelVideoFrameCallback?.(frame);
309
+ };
310
+ }, [waitingKey, videoPresentationKey, isPlaying]);
270
311
  const fitDuration = useCallback((video) => {
271
312
  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
313
  }, [resolvedMuted, sceneDuration]);
@@ -382,13 +423,13 @@ var SceneVideoBackdrop = ({
382
423
  },
383
424
  posterPlane.presentationKey
384
425
  )),
385
- (exhaustedKey === videoPresentationKey || waitingKey === videoPresentationKey) && /* @__PURE__ */ jsx(
426
+ exhaustedKey === videoPresentationKey && /* @__PURE__ */ jsx(
386
427
  "div",
387
428
  {
388
429
  role: "status",
389
- "data-media-continuity": exhaustedKey === videoPresentationKey ? "exhausted" : "waiting",
430
+ "data-media-continuity": "exhausted",
390
431
  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"
432
+ children: "Visual unavailable"
392
433
  }
393
434
  ),
394
435
  /* @__PURE__ */ jsx(
@@ -406,7 +447,6 @@ var SceneVideoBackdrop = ({
406
447
  onWaiting: () => {
407
448
  if (isPlaying) setWaitingKey(videoPresentationKey);
408
449
  },
409
- onPlaying: () => setWaitingKey(void 0),
410
450
  onLoadedData: (event) => {
411
451
  const video = event.currentTarget;
412
452
  const markPresented = () => {
@@ -423,7 +463,7 @@ var SceneVideoBackdrop = ({
423
463
  onError,
424
464
  "data-media-position": mediaPosition,
425
465
  "data-video-backdrop": persistent ? "persistent" : "scene",
426
- style: { ...mediaStyle, visibility: exhaustedKey === videoPresentationKey || waitingKey === videoPresentationKey ? "hidden" : void 0 }
466
+ style: { ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? "hidden" : void 0 }
427
467
  }
428
468
  )
429
469
  ] });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-QZAZT44G.js";
4
+ } from "./chunk-MGCD3ZJ3.js";
5
5
  import {
6
6
  editorialFont
7
7
  } from "./chunk-4YM2M62S.js";
@@ -3,7 +3,7 @@ import {
3
3
  SceneVideoBackdrop,
4
4
  getBackgroundTransform,
5
5
  resolveMediaPosition
6
- } from "./chunk-O6FBIB4L.js";
6
+ } from "./chunk-DD4ZSYKG.js";
7
7
  import {
8
8
  resolveMediaType
9
9
  } from "./chunk-224QNWRA.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-4EZVVQY7.js").then((module) => ({ default: module.SceneVideoBackdrop })));
212
+ var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-MUGIXE2Q.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 = () => () => {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-QZAZT44G.js";
4
+ } from "./chunk-MGCD3ZJ3.js";
5
5
 
6
6
  // src/visual-system/scene-templates/cinema-media.tsx
7
7
  import { jsx } from "react/jsx-runtime";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  MediaScene,
3
3
  MediaSceneTemplate
4
- } from "./chunk-Z2ZI5G7O.js";
5
- import "./chunk-QZAZT44G.js";
6
- import "./chunk-O6FBIB4L.js";
4
+ } from "./chunk-XBIKTSFY.js";
5
+ import "./chunk-MGCD3ZJ3.js";
6
+ import "./chunk-DD4ZSYKG.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-G5TYLTL5.js";
4
- import "./chunk-QZAZT44G.js";
5
- import "./chunk-O6FBIB4L.js";
3
+ } from "./chunk-JNZRVC45.js";
4
+ import "./chunk-MGCD3ZJ3.js";
5
+ import "./chunk-DD4ZSYKG.js";
6
6
  import {
7
7
  editorialLabel,
8
8
  fade
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-G5TYLTL5.js";
4
- import "./chunk-QZAZT44G.js";
5
- import "./chunk-O6FBIB4L.js";
3
+ } from "./chunk-JNZRVC45.js";
4
+ import "./chunk-MGCD3ZJ3.js";
5
+ import "./chunk-DD4ZSYKG.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-G5TYLTL5.js";
4
- import "./chunk-QZAZT44G.js";
5
- import "./chunk-O6FBIB4L.js";
3
+ } from "./chunk-JNZRVC45.js";
4
+ import "./chunk-MGCD3ZJ3.js";
5
+ import "./chunk-DD4ZSYKG.js";
6
6
  import {
7
7
  editorialLabel,
8
8
  fade
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  MediaScene
3
- } from "./chunk-Z2ZI5G7O.js";
4
- import "./chunk-QZAZT44G.js";
3
+ } from "./chunk-XBIKTSFY.js";
4
+ import "./chunk-MGCD3ZJ3.js";
5
5
  import {
6
6
  spring
7
- } from "./chunk-O6FBIB4L.js";
7
+ } from "./chunk-DD4ZSYKG.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-G5TYLTL5.js";
4
- import "./chunk-QZAZT44G.js";
5
- import "./chunk-O6FBIB4L.js";
3
+ } from "./chunk-JNZRVC45.js";
4
+ import "./chunk-MGCD3ZJ3.js";
5
+ import "./chunk-DD4ZSYKG.js";
6
6
  import {
7
7
  fade
8
8
  } from "./chunk-4YM2M62S.js";
package/dist/react.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  VideoFrame,
41
41
  getDimensions,
42
42
  sceneReadinessKey
43
- } from "./chunk-WZFLEPLM.js";
43
+ } from "./chunk-VWJBIYBM.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-3SIGVVCY.js").then((module) => ({ default: module.MediaSceneTemplate })),
97
+ "cinemaMedia": () => import("./cinema-media-AYKCBXRI.js").then((module) => ({ default: module.MediaSceneTemplate })),
98
98
  "chapterTitle": () => import("./chapter-title-YRLIQY3V.js").then((module) => ({ default: module.TitleSceneTemplate })),
99
- "editorialTimeline": () => import("./editorial-timeline-QJU4JJWB.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
- "mobileMessage": () => import("./mobile-message-ZMMTUDUQ.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
- "comparison": () => import("./comparison-VDGRWPB5.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
- "quote": () => import("./quote-BB6UJT6L.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
- "keyFigure": () => import("./key-figure-YUAQW6OE.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
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 }))
104
104
  };
105
105
 
106
106
  // src/visual-system/catalog/builtin-player.generated.ts
@@ -1537,6 +1537,7 @@ async function prepareSceneMedia(variables, signal) {
1537
1537
  var DEFAULT_MAX_CACHED_LINES = 60;
1538
1538
  var SPEECH_PREPARATION_TIMEOUT_MS = 3e3;
1539
1539
  var FALLBACK_BITS_PER_SECOND = 128e3;
1540
+ var ACTIVATION_AUDIO = "data:audio/wav;base64,UklGRrQBAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YZABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1540
1541
  var sharedContext;
1541
1542
  function actionEndpoint(endpoint, action) {
1542
1543
  const value = String(endpoint);
@@ -1689,6 +1690,14 @@ function createVideoChatVoice(options = {}) {
1689
1690
  },
1690
1691
  resume() {
1691
1692
  held = false;
1693
+ if (!disposed && !silent && !generatedElement && globalThis.navigator?.userActivation?.isActive) {
1694
+ const element = generatedElement = new Audio();
1695
+ element.src = ACTIVATION_AUDIO;
1696
+ try {
1697
+ void element.play().catch(() => void 0);
1698
+ } catch {
1699
+ }
1700
+ }
1692
1701
  if (sounding) {
1693
1702
  const fail = playbackFailure;
1694
1703
  void sounding.play().catch(() => {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SceneVideoBackdrop
3
- } from "./chunk-O6FBIB4L.js";
3
+ } from "./chunk-DD4ZSYKG.js";
4
4
  import "./chunk-OOBT4X46.js";
5
5
  export {
6
6
  SceneVideoBackdrop
@@ -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.4",
3
+ "version": "0.10.6",
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 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 || waitingKey === videoPresentationKey) && <div\n role=\"status\" data-media-continuity={exhaustedKey === videoPresentationKey ? \"exhausted\" : \"waiting\"}\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >{exhaustedKey === videoPresentationKey ? \"Visual unavailable\" : \"Loading visual\"}</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 onPlaying={() => setWaitingKey(undefined)}\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 || waitingKey === 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 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"
44
44
  },
45
45
  {
46
46
  "path": "src/visual-system/scene-templates/color-utils.ts",
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.4",
12
+ "@vanillaskyai/video": "0.10.6",
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)