@vanillaskyai/video 0.10.9 → 0.10.10
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 +6 -0
- package/dist/check-runtime.js +1 -1
- package/dist/{chunk-Q3LPOPHL.js → chunk-3S5FXE6G.js} +1 -1
- package/dist/{chunk-KUCEOG2L.js → chunk-FFKAHNYD.js} +6 -2
- package/dist/{chunk-R5ZX2LJV.js → chunk-XL3H42K5.js} +1 -1
- package/dist/{chunk-J7EGPURK.js → chunk-YERSNTFL.js} +12 -8
- package/dist/{cinema-media-WUS7CKPC.js → cinema-media-IAK3LC2J.js} +2 -2
- package/dist/{comparison-T4OYCOH3.js → comparison-MCTN376M.js} +2 -2
- package/dist/{editorial-timeline-JHQZIPNE.js → editorial-timeline-435UCJ45.js} +2 -2
- package/dist/{key-figure-YWN2OBQU.js → key-figure-BSOQ3TYN.js} +2 -2
- package/dist/{mobile-message-CDKOERRZ.js → mobile-message-GVFA56QP.js} +2 -2
- package/dist/{quote-JKABOMIG.js → quote-H2X4XGPB.js} +2 -2
- package/dist/react.js +7 -7
- package/dist/server.js +35 -5
- package/package.json +1 -1
- package/registry/items/backgrounds.json +1 -1
- package/starters/video-chat/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.10.10
|
|
8
|
+
|
|
9
|
+
- Accept multiline streamed chat JSON objects without waiting for the entire answer, while retaining bounded parsing and strict scene validation.
|
|
10
|
+
|
|
11
|
+
- Wait for a presented frame with playable future data before starting narration, retaining bounded cold startup and quick recovery for footage that stalls after playback was available.
|
|
12
|
+
|
|
7
13
|
## 0.10.9
|
|
8
14
|
|
|
9
15
|
- Use essential subject hints and explicit exclusions when selecting starter Pexels footage, and isolate cached selections by those hints. Metadata-free results remain unverified provider-ranked fallbacks.
|
package/dist/check-runtime.js
CHANGED
|
@@ -61,14 +61,18 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
61
61
|
}
|
|
62
62
|
if (isVideo) {
|
|
63
63
|
const video = layer.querySelector("video");
|
|
64
|
-
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >=
|
|
64
|
+
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
65
65
|
if (presented === video) {
|
|
66
66
|
finish(void 0, true);
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
69
|
observed = video;
|
|
70
70
|
if (video.requestVideoFrameCallback) {
|
|
71
|
-
callback = video.requestVideoFrameCallback(() =>
|
|
71
|
+
callback = video.requestVideoFrameCallback(() => {
|
|
72
|
+
callback = void 0;
|
|
73
|
+
presented = video;
|
|
74
|
+
check();
|
|
75
|
+
});
|
|
72
76
|
return;
|
|
73
77
|
}
|
|
74
78
|
finish(void 0, true);
|
|
@@ -257,7 +257,7 @@ var SceneVideoBackdrop = ({
|
|
|
257
257
|
const [waitingKey, setWaitingKey] = useState();
|
|
258
258
|
const [exhaustedKey, setExhaustedKey] = useState();
|
|
259
259
|
const videoRef = useRef(null);
|
|
260
|
-
const
|
|
260
|
+
const playableVideoUrl = useRef(void 0);
|
|
261
261
|
const startedVideoUrl = useRef(void 0);
|
|
262
262
|
const startedPlaybackId = useRef(void 0);
|
|
263
263
|
const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
|
|
@@ -278,7 +278,7 @@ var SceneVideoBackdrop = ({
|
|
|
278
278
|
const video = videoRef.current;
|
|
279
279
|
if (!video) return;
|
|
280
280
|
const expectedSource = video.getAttribute("src") === mediaUrl ? video.src : void 0;
|
|
281
|
-
let
|
|
281
|
+
let awaitingPlayback = playableVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;
|
|
282
282
|
let previousTime = video.currentTime;
|
|
283
283
|
let forwardFrames = 0;
|
|
284
284
|
let stopped = false;
|
|
@@ -287,8 +287,9 @@ var SceneVideoBackdrop = ({
|
|
|
287
287
|
const observe = (_now, metadata) => {
|
|
288
288
|
if (stopped) return;
|
|
289
289
|
const currentSource = video.currentSrc === expectedSource;
|
|
290
|
-
if (currentSource &&
|
|
291
|
-
|
|
290
|
+
if (currentSource && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
291
|
+
playableVideoUrl.current = mediaUrl;
|
|
292
|
+
awaitingPlayback = false;
|
|
292
293
|
clearTimeout(deadline);
|
|
293
294
|
deadline = setTimeout(fail, 1e3);
|
|
294
295
|
}
|
|
@@ -308,10 +309,10 @@ var SceneVideoBackdrop = ({
|
|
|
308
309
|
const fail = () => {
|
|
309
310
|
if (!stopped) {
|
|
310
311
|
stopped = true;
|
|
311
|
-
unavailable(
|
|
312
|
+
unavailable(awaitingPlayback ? "frame-readiness-timeout" : "stalled-media");
|
|
312
313
|
}
|
|
313
314
|
};
|
|
314
|
-
let deadline = setTimeout(fail,
|
|
315
|
+
let deadline = setTimeout(fail, awaitingPlayback ? 8e3 : 1e3);
|
|
315
316
|
observe();
|
|
316
317
|
return () => {
|
|
317
318
|
stopped = true;
|
|
@@ -329,7 +330,7 @@ var SceneVideoBackdrop = ({
|
|
|
329
330
|
let frame;
|
|
330
331
|
const markPresented = () => {
|
|
331
332
|
if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey || video.getAttribute("src") !== mediaUrl || video.currentSrc !== video.src) return false;
|
|
332
|
-
|
|
333
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;
|
|
333
334
|
video.dispatchEvent(new Event("vanillasky:video-frame-presented", { bubbles: true }));
|
|
334
335
|
onReadyRef.current?.();
|
|
335
336
|
setDecodedVideoUrl(mediaUrl);
|
|
@@ -449,7 +450,10 @@ var SceneVideoBackdrop = ({
|
|
|
449
450
|
onLoadedMetadata: (event) => fitDuration(event.currentTarget),
|
|
450
451
|
onEnded: (event) => continueMotion(event.currentTarget),
|
|
451
452
|
onPlay: enforceRequestedPause,
|
|
452
|
-
onPlaying:
|
|
453
|
+
onPlaying: (event) => {
|
|
454
|
+
if (event.currentTarget.currentSrc === event.currentTarget.src && event.currentTarget.getAttribute("src") === mediaUrl && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;
|
|
455
|
+
enforceRequestedPause(event);
|
|
456
|
+
},
|
|
453
457
|
onWaiting: () => {
|
|
454
458
|
if (isPlaying) setWaitingKey(videoPresentationKey);
|
|
455
459
|
},
|
package/dist/react.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
VideoFrame,
|
|
41
41
|
getDimensions,
|
|
42
42
|
sceneReadinessKey
|
|
43
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-FFKAHNYD.js";
|
|
44
44
|
import "./chunk-5JBMYQP6.js";
|
|
45
45
|
import "./chunk-224QNWRA.js";
|
|
46
46
|
import {
|
|
@@ -94,13 +94,13 @@ import { createElement } from "react";
|
|
|
94
94
|
|
|
95
95
|
// src/visual-system/catalog/builtin-loaders.generated.ts
|
|
96
96
|
var GENERATED_BUILTIN_TEMPLATE_LOADERS = {
|
|
97
|
-
"cinemaMedia": () => import("./cinema-media-
|
|
97
|
+
"cinemaMedia": () => import("./cinema-media-IAK3LC2J.js").then((module) => ({ default: module.MediaSceneTemplate })),
|
|
98
98
|
"chapterTitle": () => import("./chapter-title-2JVDU62E.js").then((module) => ({ default: module.TitleSceneTemplate })),
|
|
99
|
-
"editorialTimeline": () => import("./editorial-timeline-
|
|
100
|
-
"mobileMessage": () => import("./mobile-message-
|
|
101
|
-
"comparison": () => import("./comparison-
|
|
102
|
-
"quote": () => import("./quote-
|
|
103
|
-
"keyFigure": () => import("./key-figure-
|
|
99
|
+
"editorialTimeline": () => import("./editorial-timeline-435UCJ45.js").then((module) => ({ default: module.TimelineSceneTemplate })),
|
|
100
|
+
"mobileMessage": () => import("./mobile-message-GVFA56QP.js").then((module) => ({ default: module.NotificationSceneTemplate })),
|
|
101
|
+
"comparison": () => import("./comparison-MCTN376M.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
|
|
102
|
+
"quote": () => import("./quote-H2X4XGPB.js").then((module) => ({ default: module.QuoteSceneTemplate })),
|
|
103
|
+
"keyFigure": () => import("./key-figure-BSOQ3TYN.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
// src/visual-system/catalog/builtin-player.generated.ts
|
package/dist/server.js
CHANGED
|
@@ -1017,23 +1017,53 @@ function createChatShotPlanner(options) {
|
|
|
1017
1017
|
bodyDuration += shot.durationSec;
|
|
1018
1018
|
return scenePart(shot);
|
|
1019
1019
|
};
|
|
1020
|
+
let cursor = 0, depth = 0, quoted = false, escaped = false;
|
|
1021
|
+
const takeFrame = () => {
|
|
1022
|
+
if (cursor === 0) {
|
|
1023
|
+
buffer = buffer.trimStart();
|
|
1024
|
+
if (!buffer) return;
|
|
1025
|
+
if (buffer[0] !== "{" && buffer[0] !== "[") {
|
|
1026
|
+
const newline = buffer.indexOf("\n");
|
|
1027
|
+
if (newline < 0) return;
|
|
1028
|
+
const raw = buffer.slice(0, newline);
|
|
1029
|
+
buffer = buffer.slice(newline + 1);
|
|
1030
|
+
return raw;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
for (; cursor < buffer.length; cursor++) {
|
|
1034
|
+
const character = buffer[cursor];
|
|
1035
|
+
if (quoted) {
|
|
1036
|
+
if (escaped) escaped = false;
|
|
1037
|
+
else if (character === "\\") escaped = true;
|
|
1038
|
+
else if (character === '"') quoted = false;
|
|
1039
|
+
} else if (character === '"') quoted = true;
|
|
1040
|
+
else if (character === "{" || character === "[") depth++;
|
|
1041
|
+
else if (character === "}" || character === "]") {
|
|
1042
|
+
depth--;
|
|
1043
|
+
if (depth === 0) {
|
|
1044
|
+
const raw = buffer.slice(0, cursor + 1);
|
|
1045
|
+
buffer = buffer.slice(cursor + 1);
|
|
1046
|
+
cursor = 0;
|
|
1047
|
+
return raw;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1020
1052
|
try {
|
|
1021
1053
|
for await (const delta of upstream) {
|
|
1022
1054
|
context.signal.throwIfAborted();
|
|
1023
1055
|
if (typeof delta !== "string") throw new Error("The LLM adapter returned a non-text delta");
|
|
1024
1056
|
buffer += delta;
|
|
1025
1057
|
if (buffer.length > 32768) throw new Error("Chat plan line exceeds the bounded stream limit");
|
|
1026
|
-
let
|
|
1027
|
-
while (
|
|
1028
|
-
const raw = buffer.slice(0, newline);
|
|
1029
|
-
buffer = buffer.slice(newline + 1);
|
|
1058
|
+
let raw = takeFrame();
|
|
1059
|
+
while (raw !== void 0) {
|
|
1030
1060
|
try {
|
|
1031
1061
|
const part = line(raw);
|
|
1032
1062
|
if (part) yield JSON.stringify(part) + "\n";
|
|
1033
1063
|
} catch (cause) {
|
|
1034
1064
|
reject(cause);
|
|
1035
1065
|
}
|
|
1036
|
-
|
|
1066
|
+
raw = takeFrame();
|
|
1037
1067
|
}
|
|
1038
1068
|
}
|
|
1039
1069
|
if (buffer.trim()) {
|
package/package.json
CHANGED
|
@@ -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 { 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"
|
|
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 playableVideoUrl = 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 awaitingPlayback = playableVideoUrl.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 && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {\n playableVideoUrl.current = mediaUrl;\n awaitingPlayback = 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(awaitingPlayback ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // A decoded still with no future data is still cold, even after its first\n // frame callback. Keep the short bound only after playback was available.\n let deadline = setTimeout(fail, awaitingPlayback ? 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 if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.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={event => {\n if (event.currentTarget.currentSrc === event.currentTarget.src\n && event.currentTarget.getAttribute(\"src\") === mediaUrl\n && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n enforceRequestedPause(event);\n }}\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",
|