@vanillaskyai/video 0.10.6 → 0.10.8

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/dist/server.js CHANGED
@@ -895,14 +895,24 @@ var object = (value) => value && typeof value === "object" && !Array.isArray(val
895
895
  function text(value, maximum) {
896
896
  return typeof value === "string" && value.trim().length <= maximum ? value.trim() : "";
897
897
  }
898
- function readShot(value, clipDurationSec) {
898
+ function chapterSubject(subject) {
899
+ const normalized = subject.replace(/\s+/gu, " ");
900
+ if (normalized.length <= 65) return normalized;
901
+ const prefix = normalized.slice(0, 65);
902
+ const boundary = prefix.lastIndexOf(" ");
903
+ return boundary > 0 ? prefix.slice(0, boundary) : "";
904
+ }
905
+ function readShot(value, clipDurationSec, answerSubject = "") {
899
906
  const item = object(value);
900
907
  const narration = text(item?.narration, 2e3);
901
908
  if (!narration) throw new Error("Chat shot requires bounded authored narration");
909
+ const subject = text(item?.subject, 80);
910
+ const title = text(item?.title, 65) || chapterSubject(subject) || chapterSubject(answerSubject);
911
+ if (!title) throw new Error("Chat shot requires an authored chapter title or subject");
902
912
  return {
903
913
  narration,
904
- title: text(item?.title, 65) || text(item?.subject, 65) || "The next step",
905
- subject: text(item?.subject, 80),
914
+ title,
915
+ subject,
906
916
  action: text(item?.action, 600),
907
917
  durationSec: typeof item?.durationSec === "number" && Number.isFinite(item.durationSec) ? Math.min(clipDurationSec, Math.max(2, item.durationSec)) : clipDurationSec,
908
918
  continuity: item?.continuity === "continue" ? "continue" : "cut"
@@ -973,7 +983,7 @@ function createChatShotPlanner(options) {
973
983
  brief = { opening: text(part.opening, 300), subject: text(part.subject, 80), visualDirection: text(part.visualDirection, 600), development: text(part.development, 2e3) };
974
984
  if (part.ending) {
975
985
  try {
976
- brief.ending = readShot(part.ending, clipDurationSec);
986
+ brief.ending = readShot(part.ending, clipDurationSec, brief.subject);
977
987
  } catch (cause) {
978
988
  reject(cause);
979
989
  }
@@ -983,7 +993,7 @@ function createChatShotPlanner(options) {
983
993
  }
984
994
  if (part?.type !== "shot") throw new Error("Chat plan requires an answer brief followed by shots");
985
995
  if (!brief) throw new Error("Chat shot arrived before its answer brief");
986
- const shot = readShot(part, clipDurationSec);
996
+ const shot = readShot(part, clipDurationSec, brief.subject);
987
997
  if (shot.narration === brief.ending?.narration) return;
988
998
  if (firstBody && !continueAfterOpening(shot.narration, [options.openingLine ?? brief.opening])) return;
989
999
  const budget = (context.request.input.maxDurationSec ?? 40) - (brief.ending?.durationSec ?? clipDurationSec);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -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 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"
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\nexport type MediaRecoveryReason = \"decode-error\" | \"frame-readiness-timeout\" | \"stalled-media\" | \"playback-error\";\n\ninterface BackdropContextValue {\n mode: ExternalVideoBackdropMode;\n audioMuted: boolean;\n audioVolume: number;\n preparingNarration?: boolean;\n onMediaError?: (reason?: MediaRecoveryReason) => 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?: (reason?: MediaRecoveryReason) => 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(): ((reason?: MediaRecoveryReason) => 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, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n retainPoster?: boolean;\n persistent?: boolean;\n preparedPoster?: {\n presentationKey: string;\n mediaPoster: string;\n mediaPosition: string;\n backgroundEffect?: string;\n /** Existing global transition progress. On decoder-constrained Safari,\n * this fades the decoded incoming still above the outgoing video before\n * the single video element changes source. */\n opacity?: number;\n };\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n retainPoster = false,\n persistent = false,\n preparedPoster,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = () => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.();\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const time = metadata?.mediaTime ?? video.currentTime;\n if (video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const deadline = setTimeout(() => {\n if (!stopped) { stopped = true; unavailable(); }\n }, 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(unavailable);\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(unavailable);\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(unavailable);\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n zIndex: persistent ? 1 : undefined,\n };\n const preparedPosition = preparedPoster\n ? resolveMediaPosition(preparedPoster.mediaPosition)\n : resolvedPosition;\n const preparedTransform = getBackgroundTransform(preparedPoster?.backgroundEffect, 0, 0);\n const posterPlanes = [\n ...(persistent && mediaPoster ? [{\n presentationKey: videoPresentationKey,\n mediaPoster,\n mediaPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n opacity: 1,\n zIndex: 0,\n role: \"current\",\n }] : []),\n ...(preparedPoster && preparedPoster.presentationKey !== videoPresentationKey ? [{\n presentationKey: preparedPoster.presentationKey,\n mediaPoster: preparedPoster.mediaPoster,\n mediaPosition: preparedPosition,\n transform: preparedTransform.transform,\n transformOrigin: preparedTransform.transformOrigin,\n opacity: preparedPoster.opacity ?? 0,\n zIndex: 2,\n role: \"prepared\",\n }] : []),\n ];\n\n return (\n <>\n {posterPlanes.map((posterPlane) => (\n <img\n key={posterPlane.presentationKey}\n src={posterPlane.mediaPoster}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-video-poster-plane={posterPlane.role}\n data-video-poster-visible={posterPlane.opacity > 0 ? \"true\" : \"false\"}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: posterPlane.mediaPosition,\n transform: posterPlane.transform,\n transformOrigin: posterPlane.transformOrigin,\n zIndex: posterPlane.zIndex,\n opacity: posterPlane.opacity,\n pointerEvents: \"none\",\n }}\n />\n ))}\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={retainPoster || decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onLoadedData={(event) => {\n const video = event.currentTarget;\n const markPresented = () => {\n if (!video.isConnected) return;\n onReady?.();\n if (!retainPoster) setDecodedVideoUrl(mediaUrl);\n };\n if (video.requestVideoFrameCallback) {\n video.requestVideoFrameCallback(markPresented);\n return;\n }\n markPresented();\n }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop={persistent ? \"persistent\" : \"scene\"}\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
43
+ "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, 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 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 onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const presentedVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {\n awaitingFirstFrame = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(awaitingFirstFrame ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // Only a source that has presented a frame can be judged as stalled motion.\n let deadline = setTimeout(fail, awaitingFirstFrame ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let stopped = false;\n let frame: number | undefined;\n const markPresented = () => {\n if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return false;\n presentedVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReadyRef.current?.();\n setDecodedVideoUrl(mediaUrl);\n stopped = true;\n return true;\n };\n const observe = () => {\n if (stopped || frame !== undefined) return;\n if (video.requestVideoFrameCallback) {\n frame = video.requestVideoFrameCallback(() => {\n frame = undefined;\n if (!markPresented()) observe();\n });\n } else if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markPresented();\n };\n // A cached resource can finish loading while its Suspense tree is still\n // detached. Observe the mounted frame even if loadeddata was missed.\n video.addEventListener(\"loadeddata\", observe);\n observe();\n return () => {\n stopped = true;\n video.removeEventListener(\"loadeddata\", observe);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [mediaUrl, videoPresentationKey]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(() => unavailable());\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(() => unavailable());\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(() => unavailable());\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const enforceRequestedPause = (event: React.SyntheticEvent<HTMLVideoElement>) => {\n // WebKit may enter playback without a playing event while seeking or\n // waiting. Both native start events must honor the latest requested hold.\n if (presentationRef.current.playing) return;\n event.currentTarget.pause();\n if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;\n };\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 };\n return (\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={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 onPlay={enforceRequestedPause}\n onPlaying={enforceRequestedPause}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop=\"scene\"\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
44
44
  },
45
45
  {
46
46
  "path": "src/visual-system/scene-templates/color-utils.ts",
@@ -16,7 +16,7 @@
16
16
  "path": "src/visual-system/scene-templates/chapter-title.tsx",
17
17
  "type": "registry:component",
18
18
  "target": "vanillasky/scene-templates/chapter-title.tsx",
19
- "content": "import type { SceneTemplateProps } from './types';\n\nimport {editorialFont,fade as ease} from './editorial-typography';\n\n/** A quiet chapter beat: fade in, read, fade to black. */\nfunction TitleScene({ variables, width, height, progress }: SceneTemplateProps) {\n const title = String(variables.title ?? 'A different perspective');\n const unit = Math.min(width, height);\n const opacity = ease(progress / .22) * (1 - ease((progress - .76) / .24));\n return <div data-template=\"title\" data-title-treatment=\"quiet-fade\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',display:'flex',alignItems:'center',justifyContent:'center'}}>\n <div data-title-composition=\"centered\" style={{width:'76%',textAlign:'center',opacity,fontFamily:editorialFont,fontWeight:500,fontSize:unit * .068,lineHeight:1.18,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{title}</div>\n </div>;\n}\n\nexport const TitleSceneTemplate = TitleScene;\n"
19
+ "content": "import type { SceneTemplateProps } from './types';\n\nimport {editorialFont,fade as ease} from './editorial-typography';\n\n/** A quiet chapter beat: fade in, read, fade to black. */\nfunction TitleScene({ variables, width, height, progress, motionProgress }: SceneTemplateProps) {\n const title = String(variables.title ?? 'A different perspective');\n const unit = Math.min(width, height);\n const presentation = motionProgress ?? progress;\n const opacity = ease(presentation / .22) * (1 - ease((presentation - .76) / .24));\n return <div data-template=\"title\" data-title-treatment=\"quiet-fade\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',display:'flex',alignItems:'center',justifyContent:'center'}}>\n <div data-title-composition=\"centered\" style={{width:'76%',textAlign:'center',opacity,fontFamily:editorialFont,fontWeight:500,fontSize:unit * .068,lineHeight:1.18,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{title}</div>\n </div>;\n}\n\nexport const TitleSceneTemplate = TitleScene;\n"
20
20
  },
21
21
  {
22
22
  "path": "src/visual-system/scene-templates/types.ts",
@@ -49,15 +49,11 @@ call. A template introduction starts while footage prepares; body shots contain
49
49
  moving footage, narration and subtitles. Intent changes the visible actions and
50
50
  pacing, not the rendering pipeline.
51
51
 
52
- The SDK tries generated footage first, then relevant approved stock when the
53
- provider is unavailable, denied, or fails. Attempts, including failures, share
54
- the host's `maxGeneratedVideos` ceiling. A stock miss never broadens the subject
55
- automatically. If no relevant footage is available, narration and subtitles
56
- continue as authored chapter scenes.
57
-
58
52
  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,
53
+ provider, and both use chapter scenes when footage cannot be prepared. AI video
54
+ attempts, including failures, share the host's `maxGeneratedVideos` ceiling.
55
+ Exhausted allowance recovers directly to authored chapters without stock calls.
56
+ The Pexels adapter searches the full catalog with bounded subject matching,
61
57
  orientation selection and caching; it no longer requires a reviewed index.
62
58
  Custom interfaces must display a prominent [Pexels](https://www.pexels.com) credit.
63
59
 
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.6",
12
+ "@vanillaskyai/video": "0.10.8",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -25,11 +25,6 @@ export const handleVideoChat = createVideoChatHandler({
25
25
  output_config: { effort: "medium" },
26
26
  },
27
27
  },
28
- onFinish: ({ finishReason, text }) => {
29
- if (finishReason !== "stop") console.warn(`[planner] ${finishReason}: ${text.slice(0, 300)}`);
30
- const keyed = (text.match(/"mediaKeyword"/g) ?? []).length;
31
- console.log(`[planner] ${(text.match(/"scene\.add"/g) ?? []).length} scenes, ${keyed} asked for footage`);
32
- },
33
28
  }),
34
29
  generateText: async ({ systemPrompt, userPrompt, maxOutputTokens, signal }) => {
35
30
  const { text } = await generateText({
@@ -1,207 +0,0 @@
1
- import {
2
- BrandGradientOverlay,
3
- SceneVideoBackdrop,
4
- getBackgroundTransform,
5
- resolveMediaPosition
6
- } from "./chunk-DD4ZSYKG.js";
7
- import {
8
- resolveMediaType
9
- } from "./chunk-224QNWRA.js";
10
- import {
11
- useExternalVideoBackdrop
12
- } from "./chunk-OOBT4X46.js";
13
-
14
- // src/visual-system/scene-templates/scene-background.tsx
15
- import { useEffect, useState } from "react";
16
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
17
- function resolveMediaTreatment(value) {
18
- return value === "none" || value === "subtle" || value === "text-safe" ? value : "cinematic";
19
- }
20
- var SCRIM_STOP_COUNT = 7;
21
- function smoothstep(t) {
22
- return t * t * (3 - 2 * t);
23
- }
24
- function easedStops(peakAlpha, start, end, direction) {
25
- const alphaAt = (t) => {
26
- const eased = direction === "fade-out" ? 1 - smoothstep(t) : smoothstep(t);
27
- return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;
28
- };
29
- const stops = [];
30
- if (start > 0) stops.push(`${alphaAt(0)} 0%`);
31
- for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {
32
- const t = i / (SCRIM_STOP_COUNT - 1);
33
- const position = Number((start + (end - start) * t).toFixed(2));
34
- stops.push(`${alphaAt(t)} ${position}%`);
35
- }
36
- if (end < 100) stops.push(`${alphaAt(1)} 100%`);
37
- return stops.join(", ");
38
- }
39
- function getMediaTreatmentLayers(value, anchor = "full") {
40
- const treatment = resolveMediaTreatment(value);
41
- if (treatment === "none") return [];
42
- const vignette = {
43
- id: "vignette",
44
- background: treatment === "subtle" ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, "fade-in")})` : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, "fade-in")})`
45
- };
46
- if (treatment === "subtle") return [vignette];
47
- const textSafe = treatment === "text-safe";
48
- const layers = [vignette];
49
- if (anchor !== "bottom") {
50
- layers.push({
51
- id: "center-scrim",
52
- background: textSafe ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, "fade-out")})` : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, "fade-out")})`
53
- });
54
- }
55
- if (anchor !== "center") {
56
- layers.push({
57
- id: "bottom-scrim",
58
- background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, "fade-out")})`,
59
- style: { top: "55%" }
60
- });
61
- }
62
- return layers;
63
- }
64
- function initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster) {
65
- if (typeof window === "undefined") return "ready";
66
- if (!wantsMedia) return "ready";
67
- if (resolved === "video") return mediaPoster ? "ready" : "pending";
68
- if (typeof Image === "undefined") return "ready";
69
- const cached = new Image();
70
- cached.src = mediaUrl;
71
- return cached.complete && cached.naturalWidth > 0 ? "ready" : "pending";
72
- }
73
- function getMediaBackgroundProps(variables) {
74
- return {
75
- mediaUrl: String(variables.mediaUrl || ""),
76
- mediaType: String(variables.mediaType || "auto"),
77
- mediaPoster: String(variables.mediaPoster || ""),
78
- mediaPosition: String(variables.mediaPosition || "center"),
79
- mediaTreatment: String(variables.mediaTreatment || "cinematic")
80
- };
81
- }
82
- var SceneBackground = ({
83
- style,
84
- progress,
85
- sceneDuration,
86
- width: _width,
87
- // accepted for symmetry; not currently used in render
88
- height: _height,
89
- mediaUrl = "",
90
- mediaType = "auto",
91
- mediaPoster,
92
- mediaPosition = "center",
93
- mediaTreatment = "cinematic",
94
- textAnchor = "full",
95
- backgroundEffect,
96
- seed,
97
- isPlaying = true,
98
- beatIntensity = 0
99
- }) => {
100
- void _width;
101
- void _height;
102
- const resolved = resolveMediaType(mediaType, mediaUrl);
103
- const wantsMedia = resolved !== "gradient" && !!mediaUrl;
104
- const externalVideoBackdrop = useExternalVideoBackdrop();
105
- const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === "video";
106
- const externalVideoFailed = externalVideoBackdrop === "fallback" && resolved === "video";
107
- const externalVideoReady = externalVideoBackdrop === "ready" && resolved === "video";
108
- const [mediaPaint, setMediaPaint] = useState(
109
- () => initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster)
110
- );
111
- useEffect(() => {
112
- setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));
113
- if (!wantsMedia || resolved !== "photo") return;
114
- if (typeof Image === "undefined") return;
115
- let cancelled = false;
116
- const probe = new Image();
117
- probe.onload = () => {
118
- if (!cancelled) setMediaPaint("ready");
119
- };
120
- probe.onerror = () => {
121
- if (!cancelled) setMediaPaint("failed");
122
- };
123
- probe.src = mediaUrl;
124
- if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? "ready" : "failed");
125
- return () => {
126
- cancelled = true;
127
- probe.onload = null;
128
- probe.onerror = null;
129
- };
130
- }, [mediaUrl, mediaPoster, resolved, wantsMedia]);
131
- const showMedia = wantsMedia && mediaPaint !== "failed";
132
- const showTreatment = wantsMedia && mediaPaint === "ready";
133
- const resolvedPosition = resolveMediaPosition(mediaPosition);
134
- const resolvedTreatment = resolveMediaTreatment(mediaTreatment);
135
- const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);
136
- const gradSeed = typeof seed === "number" ? seed : typeof seed === "string" ? seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0) : 0;
137
- const bgTransform = getBackgroundTransform(
138
- backgroundEffect,
139
- progress,
140
- beatIntensity
141
- );
142
- return /* @__PURE__ */ jsxs(Fragment, { children: [
143
- (!hasExternalVideoBackdrop || externalVideoFailed) && /* @__PURE__ */ jsx(
144
- BrandGradientOverlay,
145
- {
146
- style,
147
- progress,
148
- sceneDuration,
149
- seed: gradSeed
150
- }
151
- ),
152
- showMedia && !hasExternalVideoBackdrop && (resolved === "video" ? /* @__PURE__ */ jsx(
153
- SceneVideoBackdrop,
154
- {
155
- mediaUrl,
156
- mediaPoster,
157
- mediaPosition,
158
- backgroundEffect,
159
- progress,
160
- sceneDuration,
161
- beatIntensity,
162
- isPlaying,
163
- onReady: () => setMediaPaint("ready"),
164
- onError: () => setMediaPaint("failed")
165
- }
166
- ) : /* @__PURE__ */ jsx(
167
- "img",
168
- {
169
- src: mediaUrl,
170
- alt: "",
171
- "aria-hidden": "true",
172
- draggable: false,
173
- "data-media-position": mediaPosition,
174
- style: {
175
- position: "absolute",
176
- inset: 0,
177
- transform: bgTransform.transform,
178
- transformOrigin: bgTransform.transformOrigin,
179
- width: "100%",
180
- height: "100%",
181
- objectFit: "cover",
182
- objectPosition: resolvedPosition
183
- }
184
- }
185
- )),
186
- (hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed && treatmentLayers.map((layer) => /* @__PURE__ */ jsx(
187
- "div",
188
- {
189
- "data-media-treatment": resolvedTreatment,
190
- "data-media-overlay": layer.id,
191
- style: {
192
- position: "absolute",
193
- inset: 0,
194
- background: layer.background,
195
- pointerEvents: "none",
196
- ...layer.style
197
- }
198
- },
199
- layer.id
200
- ))
201
- ] });
202
- };
203
-
204
- export {
205
- getMediaBackgroundProps,
206
- SceneBackground
207
- };
@@ -1,7 +0,0 @@
1
- import {
2
- SceneVideoBackdrop
3
- } from "./chunk-DD4ZSYKG.js";
4
- import "./chunk-OOBT4X46.js";
5
- export {
6
- SceneVideoBackdrop
7
- };
@@ -1,37 +1,3 @@
1
- // src/visual-system/scene-templates/external-video-backdrop.tsx
2
- import React from "react";
3
- import { jsx } from "react/jsx-runtime";
4
- var DEFAULT = { mode: false, audioMuted: true, audioVolume: 1 };
5
- var sharedContext = globalThis;
6
- var BackdropContext = sharedContext.__vanillaskyVideoBackdropContext ??= React.createContext(DEFAULT);
7
- function ExternalVideoBackdropProvider({
8
- mode,
9
- audioMuted = true,
10
- audioVolume = 1,
11
- preparingNarration = false,
12
- onMediaError,
13
- children
14
- }) {
15
- const value = React.useMemo(
16
- () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),
17
- [mode, audioMuted, audioVolume, preparingNarration, onMediaError]
18
- );
19
- return /* @__PURE__ */ jsx(BackdropContext.Provider, { value, children });
20
- }
21
- function useExternalVideoBackdrop() {
22
- return React.useContext(BackdropContext).mode;
23
- }
24
- function useMediaAudio() {
25
- const { audioMuted, audioVolume } = React.useContext(BackdropContext);
26
- return { muted: audioMuted, volume: audioVolume };
27
- }
28
- function useNarrationPreroll() {
29
- return React.useContext(BackdropContext).preparingNarration === true;
30
- }
31
- function useMediaFailure() {
32
- return React.useContext(BackdropContext).onMediaError;
33
- }
34
-
35
1
  // src/visual-system/scene-templates/tokens.ts
36
2
  var STYLE_PRESETS = {
37
3
  bold: {
@@ -144,6 +110,40 @@ function darken(hex, factor) {
144
110
  return `#${ch(1)}${ch(3)}${ch(5)}`;
145
111
  }
146
112
 
113
+ // src/visual-system/scene-templates/external-video-backdrop.tsx
114
+ import React from "react";
115
+ import { jsx } from "react/jsx-runtime";
116
+ var DEFAULT = { mode: false, audioMuted: true, audioVolume: 1 };
117
+ var sharedContext = globalThis;
118
+ var BackdropContext = sharedContext.__vanillaskyVideoBackdropContext ??= React.createContext(DEFAULT);
119
+ function ExternalVideoBackdropProvider({
120
+ mode,
121
+ audioMuted = true,
122
+ audioVolume = 1,
123
+ preparingNarration = false,
124
+ onMediaError,
125
+ children
126
+ }) {
127
+ const value = React.useMemo(
128
+ () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),
129
+ [mode, audioMuted, audioVolume, preparingNarration, onMediaError]
130
+ );
131
+ return /* @__PURE__ */ jsx(BackdropContext.Provider, { value, children });
132
+ }
133
+ function useExternalVideoBackdrop() {
134
+ return React.useContext(BackdropContext).mode;
135
+ }
136
+ function useMediaAudio() {
137
+ const { audioMuted, audioVolume } = React.useContext(BackdropContext);
138
+ return { muted: audioMuted, volume: audioVolume };
139
+ }
140
+ function useNarrationPreroll() {
141
+ return React.useContext(BackdropContext).preparingNarration === true;
142
+ }
143
+ function useMediaFailure() {
144
+ return React.useContext(BackdropContext).onMediaError;
145
+ }
146
+
147
147
  export {
148
148
  resolveDensity,
149
149
  resolveTokens,