@vanillaskyai/video 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/PUBLIC-API.md +43 -31
  3. package/README.md +11 -7
  4. package/dist/check-runtime.js +2 -2
  5. package/dist/{chunk-UCXQORDZ.js → chunk-G5TYLTL5.js} +1 -1
  6. package/dist/{chunk-SI6CFFAA.js → chunk-O6FBIB4L.js} +6 -5
  7. package/dist/{chunk-YZZSWGC3.js → chunk-OOBT4X46.js} +8 -3
  8. package/dist/{chunk-LJR5VLEI.js → chunk-QZAZT44G.js} +2 -2
  9. package/dist/{chunk-3U2F7C7C.js → chunk-WZFLEPLM.js} +7 -3
  10. package/dist/{chunk-VVNZCY2U.js → chunk-Z2ZI5G7O.js} +1 -1
  11. package/dist/{cinema-media-4JUYKPQI.js → cinema-media-3SIGVVCY.js} +4 -4
  12. package/dist/{comparison-3QLMUC5V.js → comparison-VDGRWPB5.js} +4 -4
  13. package/dist/{editorial-timeline-IVE2P25N.js → editorial-timeline-QJU4JJWB.js} +4 -4
  14. package/dist/{key-figure-APD56LCO.js → key-figure-YUAQW6OE.js} +4 -4
  15. package/dist/{mobile-message-WLK3WY5B.js → mobile-message-ZMMTUDUQ.js} +4 -4
  16. package/dist/{quote-Z6YET5BO.js → quote-BB6UJT6L.js} +4 -4
  17. package/dist/react.d.ts +3 -1
  18. package/dist/react.js +79 -81
  19. package/dist/{scene-video-backdrop-OWUXPXHZ.js → scene-video-backdrop-4EZVVQY7.js} +2 -2
  20. package/dist/server.d.ts +15 -3
  21. package/dist/server.js +129 -33
  22. package/dist/{types-CdBmNTGD.d.ts → types-BqB8zC9u.d.ts} +1 -1
  23. package/docs/concepts.md +2 -2
  24. package/docs/development.md +23 -0
  25. package/docs/getting-started.md +2 -2
  26. package/docs/media-and-audio.md +26 -13
  27. package/docs/performance.md +21 -10
  28. package/docs/production.md +11 -13
  29. package/docs/prompt-and-input.md +3 -3
  30. package/docs/provider-integration.md +9 -11
  31. package/docs/reference/provider-adapters.md +3 -2
  32. package/package.json +5 -2
  33. package/registry/items/backgrounds.json +2 -2
  34. package/starters/video-chat/README.md +13 -15
  35. package/starters/video-chat/package.json +1 -1
  36. package/starters/video-chat/providers/video.ts +5 -2
  37. package/starters/video-chat/server.ts +1 -1
  38. package/starters/video-chat/stock.ts +59 -31
  39. package/styles/video-chat.css +6 -61
@@ -58,14 +58,12 @@ browser speech. Supplying `generateSpeech`, `transcribe`, `searchMedia`, or
58
58
  structural and provider-neutral; their SDKs and credentials remain application
59
59
  dependencies and never enter the browser bundle.
60
60
 
61
- The planner emits a 6-9 word hook first, then continues into the scenes in the
62
- same stream. The stock lookup is a separate cancellable request, so it cannot
63
- delay speech or planning. A welcome card can carry a prewritten `opening`, which
64
- starts immediately with its already-loaded media. When generated shots are available, the
65
- same first streamed object reserves the exact first body scene. Its clip starts
66
- generating while the planner continues the story. Playback begins after its
67
- contiguous preparation cushion, without requiring the entire plan, and normal narration is written in that same planner call rather than
68
- through a second model round trip.
61
+ The planner emits a short spoken opening, then continues the answer in the same
62
+ stream. The default UI shows a chapter immediately. A welcome card can carry a
63
+ prewritten `opening`, whose narration starts without waiting for the model.
64
+ Each authored body beat prepares speech and selected footage together. Playback
65
+ starts after its contiguous preparation cushion, without requiring the entire
66
+ plan or a second model call for narration.
69
67
 
70
68
  The matching complete React interface is one component and one scoped style
71
69
  import:
@@ -84,9 +82,9 @@ export function App() {
84
82
  For a custom interface, use `useVideoChat` and render its `turns`, `welcome`,
85
83
  `suggestions`, `caption`, and `status`; the hook owns their network and playback
86
84
  lifecycle. Pass a selected card through
87
- `chat.ask(card.prompt, { opening: card.opening, openingMedia: card.media })` to
88
- start its hook and reuse its footage immediately. For typed prompts, the hook
89
- and media keyword arrive through the response stream automatically.
85
+ `chat.ask(card.prompt, { opening: card.opening })` to start its hook immediately.
86
+ Custom interfaces can still pass and render `openingMedia`; the default UI uses
87
+ the chapter. Typed prompts receive their authored opening through the stream.
90
88
 
91
89
  Any AI SDK `LanguageModel` works in both `streamText` and `generateText`. Keep
92
90
  selection in one server-only module when an application supports several text
@@ -96,8 +96,9 @@ stream-reconnect contract.
96
96
 
97
97
  Set `maxGeneratedVideos` on `createVideoChatHandler` to a nonnegative safe integer
98
98
  (default `5`). This is a per-response generation attempt limit, including
99
- failures. Default chat attempts generated footage before relevant stock. Use `0` to keep the configured full mode on
100
- stock footage. Stock lookup is independent of this limit. Retries inside your
99
+ failures. AI mode uses authored chapter recovery after the allowance is reached;
100
+ `0` skips all generated footage. Pexels mode searches stock independently and
101
+ never consumes the generated-video allowance. Retries inside your
101
102
  provider callback can incur additional charges; bound those separately. Never
102
103
  copy an untrusted request value into this application-owned option.
103
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -116,7 +116,10 @@
116
116
  "verify:api": "npm run build && node scripts/verify-public-api-surface.mjs",
117
117
  "verify:package": "npm run build && node scripts/verify-packed-package.mjs",
118
118
  "release:build": "node scripts/release-build.mjs",
119
- "acceptance:chat": "tsx scripts/acceptance/run.ts"
119
+ "acceptance:chat": "tsx scripts/acceptance/run.ts",
120
+ "dev:chat": "vite --config dev/chat/vite.config.ts --open /dev/chat/",
121
+ "check:chat": "node scripts/chat-check.mjs",
122
+ "verify:release": "node scripts/chat-verify.mjs"
120
123
  },
121
124
  "peerDependencies": {
122
125
  "react": ">=18 <20",
@@ -28,7 +28,7 @@
28
28
  "path": "src/visual-system/scene-templates/external-video-backdrop.tsx",
29
29
  "type": "registry:lib",
30
30
  "target": "vanillasky/scene-templates/external-video-backdrop.tsx",
31
- "content": "import React from \"react\";\n\n// Source-owned templates may live in a consumer's tree while VideoFrame comes\n// from the package. Both copies must observe the same internal context or the\n// consumer template would mount a second video over the player-owned plane,\n// and would never inherit the player's native media audio state.\nexport type ExternalVideoBackdropMode = false | \"pending\" | \"ready\" | \"fallback\";\n\ninterface BackdropContextValue {\n mode: ExternalVideoBackdropMode;\n audioMuted: boolean;\n audioVolume: number;\n preparingNarration?: boolean;\n}\n\nconst DEFAULT: BackdropContextValue = { mode: false, audioMuted: true, audioVolume: 1 };\n\nconst sharedContext = globalThis as typeof globalThis & {\n __vanillaskyVideoBackdropContext?: React.Context<BackdropContextValue>;\n};\nconst BackdropContext = sharedContext.__vanillaskyVideoBackdropContext\n ??= React.createContext<BackdropContextValue>(DEFAULT);\n\nexport function ExternalVideoBackdropProvider({\n mode,\n audioMuted = true,\n audioVolume = 1,\n preparingNarration = false,\n children,\n}: {\n mode: ExternalVideoBackdropMode;\n audioMuted?: boolean;\n audioVolume?: number;\n preparingNarration?: boolean;\n children: React.ReactNode;\n}) {\n const value = React.useMemo(\n () => ({ mode, audioMuted, audioVolume, preparingNarration }),\n [mode, audioMuted, audioVolume, preparingNarration],\n );\n return (\n <BackdropContext.Provider value={value}>\n {children}\n </BackdropContext.Provider>\n );\n}\n\nexport function useExternalVideoBackdrop(): ExternalVideoBackdropMode {\n return React.useContext(BackdropContext).mode;\n}\n\nexport function useMediaAudio(): { muted: boolean; volume: number } {\n const { audioMuted, audioVolume } = React.useContext(BackdropContext);\n return { muted: audioMuted, volume: audioVolume };\n}\n\n/** Internal first-frame priming state; an explicit viewer pause never sets it. */\nexport function useNarrationPreroll(): boolean {\n return React.useContext(BackdropContext).preparingNarration === true;\n}\n"
31
+ "content": "import React from \"react\";\n\n// Source-owned templates may live in a consumer's tree while VideoFrame comes\n// from the package. Both copies must observe the same internal context or the\n// consumer template would mount a second video over the player-owned plane,\n// and would never inherit the player's native media audio state.\nexport type ExternalVideoBackdropMode = false | \"pending\" | \"ready\" | \"fallback\";\n\ninterface BackdropContextValue {\n mode: ExternalVideoBackdropMode;\n audioMuted: boolean;\n audioVolume: number;\n preparingNarration?: boolean;\n onMediaError?: () => void;\n}\n\nconst DEFAULT: BackdropContextValue = { mode: false, audioMuted: true, audioVolume: 1 };\n\nconst sharedContext = globalThis as typeof globalThis & {\n __vanillaskyVideoBackdropContext?: React.Context<BackdropContextValue>;\n};\nconst BackdropContext = sharedContext.__vanillaskyVideoBackdropContext\n ??= React.createContext<BackdropContextValue>(DEFAULT);\n\nexport function ExternalVideoBackdropProvider({\n mode,\n audioMuted = true,\n audioVolume = 1,\n preparingNarration = false,\n onMediaError,\n children,\n}: {\n mode: ExternalVideoBackdropMode;\n audioMuted?: boolean;\n audioVolume?: number;\n preparingNarration?: boolean;\n onMediaError?: () => void;\n children: React.ReactNode;\n}) {\n const value = React.useMemo(\n () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),\n [mode, audioMuted, audioVolume, preparingNarration, onMediaError],\n );\n return (\n <BackdropContext.Provider value={value}>\n {children}\n </BackdropContext.Provider>\n );\n}\n\nexport function useExternalVideoBackdrop(): ExternalVideoBackdropMode {\n return React.useContext(BackdropContext).mode;\n}\n\nexport function useMediaAudio(): { muted: boolean; volume: number } {\n const { audioMuted, audioVolume } = React.useContext(BackdropContext);\n return { muted: audioMuted, volume: audioVolume };\n}\n\n/** Internal first-frame priming state; an explicit viewer pause never sets it. */\nexport function useNarrationPreroll(): boolean {\n return React.useContext(BackdropContext).preparingNarration === true;\n}\n\n/** Routes local decoder/playback failures to the scene-owned recovery surface. */\nexport function useMediaFailure(): (() => void) | undefined {\n return React.useContext(BackdropContext).onMediaError;\n}\n"
32
32
  },
33
33
  {
34
34
  "path": "src/visual-system/scene-templates/media-position.ts",
@@ -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, 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 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 const continuityReplay = useRef<string | undefined>(undefined);\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 }\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 planner supplies short shots as the normal coverage. A single replay\n // bridges exceptional speech overrun/late delivery; never loop indefinitely.\n if (!resolvedMuted || continuityReplay.current === videoPresentationKey) {\n setExhaustedKey(videoPresentationKey);\n return;\n }\n continuityReplay.current = videoPresentationKey;\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 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"
44
44
  },
45
45
  {
46
46
  "path": "src/visual-system/scene-templates/color-utils.ts",
@@ -53,21 +53,19 @@ The SDK tries generated footage first, then relevant approved stock when the
53
53
  provider is unavailable, denied, or fails. Attempts, including failures, share
54
54
  the host's `maxGeneratedVideos` ceiling. A stock miss never broadens the subject
55
55
  automatically. If no relevant footage is available, narration and subtitles
56
- continue with an unavailable-visual state.
57
-
58
- Plan narration that fits the provider's five-second clips. Playback measures
59
- speech and prepares upcoming media before cuts. Verify full answers, including
60
- longer-than-expected speech and late or failed footage; a finished clip must not
61
- freeze while the answer keeps speaking.
62
-
63
- ## Reviewed stock
64
-
65
- The starter's `approvedStock` index in `stock.ts` is deliberately empty. Add an
66
- asset only after inspecting its content, poster and allowed orientations. List
67
- literal matching queries and a reviewed description. Add those available
68
- queries to your host instructions so the planner can choose them when relevant.
69
- An unreviewed query returns `null` without a network search or unrelated result.
70
- The host owns licensing, storage, clip rendition and retention.
56
+ continue as authored chapter scenes.
57
+
58
+ Choose AI video or Pexels in Settings. Each mode uses only its selected footage
59
+ provider, and both use chapter scenes when footage cannot be prepared. The
60
+ Pexels adapter searches the full catalog with bounded subject matching,
61
+ orientation selection and caching; it no longer requires a reviewed index.
62
+ Custom interfaces must display a prominent [Pexels](https://www.pexels.com) credit.
63
+
64
+ Speech and video prepare together. Keep `generatedClipDurationSec` aligned with
65
+ the duration actually requested by the video adapter (five seconds by default).
66
+ The planner writes natural short beats; measured speech controls scene timing.
67
+ Silent footage loops through any remaining narration. Cancellation stops pending
68
+ work and playback, and failed media becomes the authored chapter.
71
69
 
72
70
  The provider names its own model. Override the tested defaults with
73
71
  `ANTHROPIC_PLANNER_MODEL`, `ANTHROPIC_NARRATION_MODEL`, or `FAL_VIDEO_MODEL`
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.3",
12
+ "@vanillaskyai/video": "0.10.4",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -1,6 +1,8 @@
1
1
  import { fal } from "@fal-ai/client";
2
2
  import type { VideoChatHandlerOptions } from "@vanillaskyai/video/server";
3
3
 
4
+ // Change only to a duration supported by the selected model and your allowance.
5
+ const CLIP_DURATION_SEC = 5;
4
6
  const VIDEO_MODEL = process.env.FAL_VIDEO_MODEL ?? "minimax/h3-max-turbo/text-to-video";
5
7
 
6
8
  async function filmScene(
@@ -23,7 +25,7 @@ async function filmScene(
23
25
  "Natural scene sound only when appropriate. No speech, dialogue, singing, music or voiceover; narration is added separately.",
24
26
  generatedLook,
25
27
  ].filter(Boolean).join("\n\n"),
26
- duration: 5,
28
+ duration: CLIP_DURATION_SEC,
27
29
  resolution: "480P",
28
30
  aspect_ratio: orientation === "portrait" ? "9:16" : "16:9",
29
31
  prompt_expansion_mode: "balanced",
@@ -44,7 +46,8 @@ async function filmScene(
44
46
  }
45
47
 
46
48
  /** Optional generated video and transcription; credentials remain server-only. */
47
- export const videoProvider: Pick<VideoChatHandlerOptions, "generateVideo" | "transcribe"> = {
49
+ export const videoProvider: Pick<VideoChatHandlerOptions, "generateVideo" | "transcribe" | "generatedClipDurationSec"> = {
50
+ generatedClipDurationSec: CLIP_DURATION_SEC,
48
51
  generateVideo: process.env.FAL_KEY
49
52
  ? async (query, { generatedLook, orientation, signal, scene }) => ({
50
53
  url: await filmScene(query, generatedLook, orientation, signal, typeof scene?.variables.shotDirection === "string" ? scene.variables.shotDirection : undefined),
@@ -42,7 +42,7 @@ export const handleVideoChat = createVideoChatHandler({
42
42
  return text;
43
43
  },
44
44
  searchMedia: process.env.PEXELS_API_KEY
45
- ? (query, { orientation, signal, fallbackQuery }) => findStockFootage(query, orientation, signal, fallbackQuery)
45
+ ? (query, { orientation, signal }) => findStockFootage(query, orientation, signal)
46
46
  : undefined,
47
47
  ...providers,
48
48
  welcome: {
@@ -1,38 +1,66 @@
1
1
  import type { VideoOrientation } from "@vanillaskyai/video";
2
2
 
3
- export interface ApprovedStock {
4
- /** Literal phrases the inspected clip actually depicts; no broad topic aliases. */
5
- queries: readonly string[];
6
- orientations: readonly VideoOrientation[];
7
- description: string;
8
- reviewedAt: string;
9
- media: { url: string; type: "video" | "image"; posterUrl?: string };
3
+ interface StockVideo {
4
+ url: string;
5
+ type: "video";
6
+ posterUrl?: string;
10
7
  }
11
-
12
- /** Add assets only after watching the clip and checking its crop and poster.
13
- * An empty collection deliberately declines stock instead of guessing from
14
- * search rank. The host can maintain this reviewed index independently of the SDK.
15
- */
16
- export const approvedStock: readonly ApprovedStock[] = [];
17
-
18
- const normalize = (value: string) => value.trim().toLowerCase().replace(/\s+/g, ' ');
19
- function approvedUrl(value: string): boolean {
20
- try { const url=new URL(value);return url.protocol==='https:' && !url.username && !url.password && !url.port; }
21
- catch { return false; }
8
+ interface PexelsVideo {
9
+ url?: string;
10
+ image?: string;
11
+ video_files?: { link?: string; width?: number; height?: number; file_type?: string }[];
12
+ }
13
+ const cache = new Map<string, { expires: number; media: StockVideo | null }>();
14
+ const words = (value: string): string[] => value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
15
+ function pexelsUrl(value: unknown): value is string {
16
+ if (typeof value !== "string") return false;
17
+ try {
18
+ const url = new URL(value);
19
+ return url.protocol === "https:" && !url.username && !url.password && !url.port
20
+ && (url.hostname === "pexels.com" || url.hostname.endsWith(".pexels.com"));
21
+ } catch { return false; }
22
22
  }
23
23
 
24
- export async function findStockFootage(
25
- query: string,
26
- orientation: VideoOrientation,
27
- signal: AbortSignal,
28
- _fallbackQuery?: string,
29
- index: readonly ApprovedStock[] = approvedStock,
30
- ) {
24
+ /** Full catalog search. Metadata establishes subject relevance, not factual proof.
25
+ * Applications using this adapter must display a prominent link to Pexels.
26
+ * https://www.pexels.com/api/documentation/#guidelines
27
+ */
28
+ export async function findStockFootage(query: string, orientation: VideoOrientation, signal: AbortSignal) {
29
+ signal.throwIfAborted();
30
+ const normalized = query.trim().toLowerCase().replace(/\s+/g, " ");
31
+ const tokens = words(normalized);
32
+ const key = `${orientation}:${normalized}`;
33
+ const apiKey = process.env.PEXELS_API_KEY;
34
+ if (!apiKey || !tokens.length || normalized.length > 80 || tokens.length > 8) return null;
35
+ const existing = cache.get(key);
36
+ if (existing && existing.expires > Date.now()) return existing.media;
37
+ const url = new URL("https://api.pexels.com/v1/videos/search");
38
+ url.search = new URLSearchParams({ query: normalized, orientation, per_page: "12", size: "medium" }).toString();
39
+ const response = await fetch(url, { headers: { Authorization: apiKey }, signal });
40
+ signal.throwIfAborted();
41
+ if (!response.ok) return null;
42
+ const result = await response.json() as { videos?: PexelsVideo[] };
31
43
  signal.throwIfAborted();
32
- const intent=normalize(query);
33
- if(!intent || intent.length>80 || intent.split(' ').length>8)return null;
34
- const entry=index.find(asset=>asset.reviewedAt && asset.description && asset.orientations.includes(orientation)
35
- && asset.queries.some(candidate=>normalize(candidate)===intent)
36
- && approvedUrl(asset.media.url) && (!asset.media.posterUrl || approvedUrl(asset.media.posterUrl)));
37
- return entry ? {...entry.media} : null;
44
+ let selected: StockVideo | null = null, bestScore = 0;
45
+ for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
46
+ if (!pexelsUrl(video.url)) continue;
47
+ const subject = words(new URL(video.url).pathname);
48
+ 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;
51
+ const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
52
+ file.file_type === "video/mp4" && pexelsUrl(file.link)
53
+ && Number.isFinite(file.width) && Number.isFinite(file.height)
54
+ && Math.min(file.width!, file.height!) >= 360
55
+ && (orientation === "portrait" ? file.height! > file.width! : file.width! >= file.height!),
56
+ ).sort((a, b) => Math.abs(Math.max(a.width!, a.height!) - 1280) - Math.abs(Math.max(b.width!, b.height!) - 1280));
57
+ const file = files[0];
58
+ if (!file || matches <= bestScore) continue;
59
+ bestScore = matches;
60
+ selected = { url: file.link!, type: "video", ...(pexelsUrl(video.image) ? { posterUrl: video.image } : {}) };
61
+ }
62
+ // Bounded process-local cache; no request signal or credentials are retained.
63
+ if (cache.size >= 128) cache.delete(cache.keys().next().value!);
64
+ cache.set(key, { expires: Date.now() + (selected ? 24 * 60 * 60_000 : 60_000), media: selected });
65
+ return selected;
38
66
  }
@@ -193,59 +193,6 @@
193
193
 
194
194
  .vanillasky-video-chat .stage :where(video, canvas, img) { max-width: 100%; max-height: 100%; object-fit: contain; }
195
195
 
196
- .vanillasky-video-chat .ground {
197
- position: absolute;
198
- inset: -30%;
199
- background: radial-gradient(closest-side, oklch(1 0 0 / 28%), transparent 70%);
200
- animation: vanillasky-video-chat-drift 9s ease-in-out infinite alternate;
201
- }
202
-
203
- @keyframes vanillasky-video-chat-drift {
204
- from { transform: translate3d(-12%, -8%, 0) scale(1); }
205
- to { transform: translate3d(14%, 10%, 0) scale(1.25); }
206
- }
207
-
208
- .vanillasky-video-chat .asked {
209
- margin: 0 auto;
210
- padding: 0 1.5rem;
211
- display: grid;
212
- gap: 1.1rem;
213
- justify-items: center;
214
- align-content: center;
215
- text-align: center;
216
- color: var(--vs-on-media);
217
- }
218
-
219
- .vanillasky-video-chat .asked-prompt {
220
- margin: 0;
221
- max-width: 26ch;
222
- font-size: var(--vs-text-display);
223
- line-height: var(--vs-lead-display);
224
- letter-spacing: var(--vs-track-display);
225
- font-weight: 700;
226
- text-wrap: balance;
227
- animation: vanillasky-video-chat-step-in 620ms ease both;
228
- }
229
-
230
- .vanillasky-video-chat .asked-step {
231
- margin: 0;
232
- font-size: var(--vs-text-subhead);
233
- line-height: var(--vs-lead-subhead);
234
- letter-spacing: var(--vs-track-subhead);
235
- font-weight: 500;
236
- background: linear-gradient(100deg, oklch(1 0 0 / 45%) 20%, oklch(1 0 0) 42%, oklch(1 0 0 / 45%) 64%);
237
- background-size: 220% 100%;
238
- -webkit-background-clip: text;
239
- background-clip: text;
240
- color: transparent;
241
- animation: vanillasky-video-chat-sweep 2.4s linear infinite, vanillasky-video-chat-step-in 520ms ease both;
242
- }
243
-
244
- @keyframes vanillasky-video-chat-step-in {
245
- from { opacity: 0; transform: translateY(6px); }
246
- to { opacity: 1; transform: none; }
247
- }
248
-
249
196
  .vanillasky-video-chat .welcome { position: absolute; inset: 0; overflow: hidden; }
250
197
 
251
198
  .vanillasky-video-chat .frame-media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
@@ -463,7 +410,6 @@
463
410
  animation: vanillasky-video-chat-fade-in 320ms ease both;
464
411
  }
465
412
 
466
- @keyframes vanillasky-video-chat-sweep { from { background-position: 140% 0; } to { background-position: -40% 0; } }
467
413
 
468
414
  @keyframes vanillasky-video-chat-fade-in { from { opacity: 0; } to { opacity: 1; } }
469
415
 
@@ -573,7 +519,6 @@
573
519
  animation-iteration-count: 1 !important;
574
520
  transition-duration: 0.01ms !important;
575
521
  }
576
- .vanillasky-video-chat .asked-step { color: var(--vs-on-media); -webkit-text-fill-color: currentColor; }
577
522
  }
578
523
 
579
524
  .vanillasky-video-chat {
@@ -1053,12 +998,7 @@
1053
998
  @media (max-width: 700px) {
1054
999
  .vanillasky-video-chat .sheet-popover { max-height: calc(100% - 102px); }
1055
1000
  }
1056
- .vanillasky-video-chat .opening-wash {
1057
- position: absolute;
1058
- inset: 0;
1059
- background: linear-gradient(180deg, rgb(8 11 22 / 12%), rgb(8 11 22 / 42%));
1060
- pointer-events: none;
1061
- }
1001
+
1062
1002
  .vanillasky-video-chat .ending { pointer-events: none; }
1063
1003
  .vanillasky-video-chat .ending .cards { pointer-events: auto; }
1064
1004
 
@@ -1138,3 +1078,8 @@
1138
1078
  backdrop-filter: none;
1139
1079
  -webkit-backdrop-filter: none;
1140
1080
  }
1081
+
1082
+ .vanillasky-video-chat .opening-chapter { animation: vanillasky-chapter-enter 450ms ease-out both; }
1083
+ .vanillasky-video-chat .media-credit { color: var(--vs-fg); font-size: 11px; margin-left: 12px; text-underline-offset: 3px; }
1084
+ @keyframes vanillasky-chapter-enter { from { opacity: 0; } to { opacity: 1; } }
1085
+ @media (prefers-reduced-motion: reduce) { .vanillasky-video-chat .opening-chapter { animation: none; } }