@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.
- package/CHANGELOG.md +9 -0
- package/PUBLIC-API.md +43 -31
- package/README.md +11 -7
- package/dist/check-runtime.js +2 -2
- package/dist/{chunk-UCXQORDZ.js → chunk-G5TYLTL5.js} +1 -1
- package/dist/{chunk-SI6CFFAA.js → chunk-O6FBIB4L.js} +6 -5
- package/dist/{chunk-YZZSWGC3.js → chunk-OOBT4X46.js} +8 -3
- package/dist/{chunk-LJR5VLEI.js → chunk-QZAZT44G.js} +2 -2
- package/dist/{chunk-3U2F7C7C.js → chunk-WZFLEPLM.js} +7 -3
- package/dist/{chunk-VVNZCY2U.js → chunk-Z2ZI5G7O.js} +1 -1
- package/dist/{cinema-media-4JUYKPQI.js → cinema-media-3SIGVVCY.js} +4 -4
- package/dist/{comparison-3QLMUC5V.js → comparison-VDGRWPB5.js} +4 -4
- package/dist/{editorial-timeline-IVE2P25N.js → editorial-timeline-QJU4JJWB.js} +4 -4
- package/dist/{key-figure-APD56LCO.js → key-figure-YUAQW6OE.js} +4 -4
- package/dist/{mobile-message-WLK3WY5B.js → mobile-message-ZMMTUDUQ.js} +4 -4
- package/dist/{quote-Z6YET5BO.js → quote-BB6UJT6L.js} +4 -4
- package/dist/react.d.ts +3 -1
- package/dist/react.js +79 -81
- package/dist/{scene-video-backdrop-OWUXPXHZ.js → scene-video-backdrop-4EZVVQY7.js} +2 -2
- package/dist/server.d.ts +15 -3
- package/dist/server.js +129 -33
- package/dist/{types-CdBmNTGD.d.ts → types-BqB8zC9u.d.ts} +1 -1
- package/docs/concepts.md +2 -2
- package/docs/development.md +23 -0
- package/docs/getting-started.md +2 -2
- package/docs/media-and-audio.md +26 -13
- package/docs/performance.md +21 -10
- package/docs/production.md +11 -13
- package/docs/prompt-and-input.md +3 -3
- package/docs/provider-integration.md +9 -11
- package/docs/reference/provider-adapters.md +3 -2
- package/package.json +5 -2
- package/registry/items/backgrounds.json +2 -2
- package/starters/video-chat/README.md +13 -15
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/providers/video.ts +5 -2
- package/starters/video-chat/server.ts +1 -1
- package/starters/video-chat/stock.ts +59 -31
- 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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
100
|
-
|
|
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
|
+
"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
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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`
|
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
}
|
package/styles/video-chat.css
CHANGED
|
@@ -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
|
-
|
|
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; } }
|