@vanillaskyai/video 0.10.5 → 0.10.7
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 +17 -0
- package/dist/{chapter-title-YRLIQY3V.js → chapter-title-2JVDU62E.js} +1 -1
- package/dist/check-runtime.js +1 -1
- package/dist/{chunk-VWJBIYBM.js → chunk-24TCMDAA.js} +23 -39
- package/dist/{chunk-EWWCQFTI.js → chunk-7AA2JWHZ.js} +3 -2
- package/dist/{chunk-JNZRVC45.js → chunk-MKMCX3AF.js} +1 -1
- package/dist/{chunk-DD4ZSYKG.js → chunk-OBRKB7PL.js} +21 -5
- package/dist/{chunk-XBIKTSFY.js → chunk-PMJNBR4F.js} +1 -1
- package/dist/{chunk-MGCD3ZJ3.js → chunk-X4RGAEL5.js} +1 -1
- package/dist/{cinema-media-AYKCBXRI.js → cinema-media-BVQFZL3A.js} +3 -3
- package/dist/{comparison-74B2XEH4.js → comparison-T27C7FSV.js} +3 -3
- package/dist/{editorial-timeline-DUW7C75H.js → editorial-timeline-JADHHNEV.js} +3 -3
- package/dist/{key-figure-OEWIMPGS.js → key-figure-TZQWPFMI.js} +3 -3
- package/dist/{mobile-message-KBLF3WUR.js → mobile-message-GADX632R.js} +3 -3
- package/dist/{quote-QCGR2HOU.js → quote-FRCEC34C.js} +3 -3
- package/dist/react.js +44 -21
- package/dist/{scene-video-backdrop-MUGIXE2Q.js → scene-video-backdrop-NK2XK3XE.js} +1 -1
- package/dist/server.js +15 -5
- package/docs/development.md +1 -1
- package/package.json +1 -1
- package/registry/items/backgrounds.json +1 -1
- package/registry/items/chapterTitle.json +1 -1
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/stock.ts +12 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.10.7
|
|
8
|
+
|
|
9
|
+
- Reuse the first presented video frame at scene handoffs so a second readiness observation cannot briefly interrupt continuous narration.
|
|
10
|
+
|
|
11
|
+
- Keep an already-speaking paragraph uninterrupted across brief visual handoffs, while genuinely late footage still pauses narration until it can play.
|
|
12
|
+
|
|
13
|
+
- Keep the final chapter visible through answer completion, including recovery from failed footage.
|
|
14
|
+
- Derive recovery titles from authored titles or subject excerpts instead of a generic placeholder.
|
|
15
|
+
|
|
16
|
+
- Let cold footage finish its bounded initial load before treating it as stalled. Require the actual new source to present a frame before narration starts, and keep source changes from cancelling their own native load.
|
|
17
|
+
|
|
18
|
+
## 0.10.6
|
|
19
|
+
|
|
20
|
+
- Initialize the reusable narration audio element during the existing user gesture so delayed first speech can play on Safari.
|
|
21
|
+
|
|
22
|
+
- Accept valid Pexels search results without descriptive URL slugs. Rank available matching metadata above unknown relevance, without requiring most query words to appear in the slug.
|
|
23
|
+
|
|
7
24
|
## 0.10.5
|
|
8
25
|
|
|
9
26
|
- Keep footage visible when native playback reports waiting without another playing event. Observe resumed motion directly and recover to the authored chapter after one second without progress, preserving narration.
|
package/dist/check-runtime.js
CHANGED
|
@@ -33,17 +33,19 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
33
33
|
let frame = 0;
|
|
34
34
|
let callback;
|
|
35
35
|
let observed;
|
|
36
|
+
let presented;
|
|
37
|
+
const root = marker.current?.closest("[data-video-frame]");
|
|
36
38
|
const start = performance.now();
|
|
37
39
|
const finish = (error, actualVideoFrame = false) => {
|
|
38
40
|
if (!stopped) {
|
|
39
41
|
stopped = true;
|
|
42
|
+
if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
|
|
40
43
|
if (error && onFailureRef.current) onFailureRef.current();
|
|
41
44
|
else report(key, error, actualVideoFrame);
|
|
42
45
|
}
|
|
43
46
|
};
|
|
44
47
|
const check = () => {
|
|
45
48
|
if (stopped) return;
|
|
46
|
-
const root = marker.current?.closest("[data-video-frame]");
|
|
47
49
|
if (fallback && root?.querySelector("[data-scene-fallback]") && !root.querySelector("[data-template-loading]") && document.fonts?.status !== "loading") {
|
|
48
50
|
finish();
|
|
49
51
|
return;
|
|
@@ -60,7 +62,11 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
60
62
|
if (isVideo) {
|
|
61
63
|
const persistent = root.querySelector("[data-persistent-video-scene-id]");
|
|
62
64
|
const video = (persistent?.getAttribute("data-persistent-video-scene-id") === scene.id ? persistent : layer)?.querySelector("video");
|
|
63
|
-
if (video && video.getAttribute("src") === mediaUrl && video.readyState >= 2) {
|
|
65
|
+
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= 2) {
|
|
66
|
+
if (presented === video) {
|
|
67
|
+
finish(void 0, true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
64
70
|
observed = video;
|
|
65
71
|
if (video.requestVideoFrameCallback) {
|
|
66
72
|
callback = video.requestVideoFrameCallback(() => finish(void 0, true));
|
|
@@ -83,9 +89,20 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
83
89
|
}
|
|
84
90
|
frame = requestAnimationFrame(check);
|
|
85
91
|
};
|
|
92
|
+
const onPresented = (event) => {
|
|
93
|
+
const video = event.target;
|
|
94
|
+
if (!(video instanceof HTMLVideoElement) || stopped || video.getAttribute("src") !== String(scene.variables.mediaUrl || "") || video.currentSrc !== video.src) return;
|
|
95
|
+
presented = video;
|
|
96
|
+
cancelAnimationFrame(frame);
|
|
97
|
+
if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
|
|
98
|
+
callback = void 0;
|
|
99
|
+
check();
|
|
100
|
+
};
|
|
101
|
+
root?.addEventListener("vanillasky:video-frame-presented", onPresented);
|
|
86
102
|
check();
|
|
87
103
|
const timeout = setTimeout(() => finish(new Error("Scene media did not become ready")), 8e3);
|
|
88
104
|
return () => {
|
|
105
|
+
root?.removeEventListener("vanillasky:video-frame-presented", onPresented);
|
|
89
106
|
stopped = true;
|
|
90
107
|
clearTimeout(timeout);
|
|
91
108
|
cancelAnimationFrame(frame);
|
|
@@ -94,39 +111,6 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
94
111
|
}, [key, report, scene, playing, fallback]);
|
|
95
112
|
return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
|
|
96
113
|
}
|
|
97
|
-
function PreparedSceneReadiness({ scene }) {
|
|
98
|
-
const marker = useRef(null);
|
|
99
|
-
const report = useContext(MountedReadinessContext);
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
if (!report) return;
|
|
102
|
-
let frame = 0;
|
|
103
|
-
let stopped = false;
|
|
104
|
-
const check = () => {
|
|
105
|
-
if (stopped) return;
|
|
106
|
-
const root = marker.current?.closest("[data-video-frame]");
|
|
107
|
-
const incoming = root?.querySelector("[data-scene-layer='incoming']");
|
|
108
|
-
const video = incoming?.getAttribute("data-layer-scene-id") === scene.id ? incoming.querySelector("video") : void 0;
|
|
109
|
-
if (video && video.getAttribute("src") === scene.variables.mediaUrl && video.readyState >= 2) {
|
|
110
|
-
report(sceneReadinessKey(scene), void 0, false, true);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const image = [...root?.querySelectorAll("img[data-video-poster-plane='prepared']") ?? []].find((image2) => image2.getAttribute("src") === scene.variables.mediaPoster);
|
|
114
|
-
if (image?.complete && image.naturalWidth > 0) {
|
|
115
|
-
void image.decode().then(() => {
|
|
116
|
-
if (!stopped && image.isConnected) report(sceneReadinessKey(scene), void 0, false, true);
|
|
117
|
-
}).catch(() => void 0);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
frame = requestAnimationFrame(check);
|
|
121
|
-
};
|
|
122
|
-
check();
|
|
123
|
-
return () => {
|
|
124
|
-
stopped = true;
|
|
125
|
-
cancelAnimationFrame(frame);
|
|
126
|
-
};
|
|
127
|
-
}, [scene, report]);
|
|
128
|
-
return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
|
|
129
|
-
}
|
|
130
114
|
|
|
131
115
|
// src/player/video-frame.tsx
|
|
132
116
|
import {
|
|
@@ -209,7 +193,7 @@ var SceneBoundary = class extends Component {
|
|
|
209
193
|
}
|
|
210
194
|
};
|
|
211
195
|
var SCENE_TRANSITION_SECONDS = 0.3;
|
|
212
|
-
var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-
|
|
196
|
+
var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-NK2XK3XE.js").then((module) => ({ default: module.SceneVideoBackdrop })));
|
|
213
197
|
var MEDIA_PREROLL_SECONDS = 1.2;
|
|
214
198
|
var CONTIGUITY_ULP_FACTOR = 4;
|
|
215
199
|
var subscribeToDecoderPolicy = () => () => {
|
|
@@ -447,7 +431,6 @@ function VideoFrame({
|
|
|
447
431
|
const persistentVideoKey = persistentVideoRange ? `${persistentVideoRange.scene.id}\0${String(persistentVideoRange.scene.variables.mediaUrl || "")}` : void 0;
|
|
448
432
|
const firstVideoRange = timeline.find(sceneHasVideoBackdrop);
|
|
449
433
|
const posterPreparationRange = activeUsesPersistentVideo ? contiguousNext && sceneHasVideoBackdrop(contiguousNext) ? contiguousNext : activeIndex === timeline.length - 1 && firstVideoRange?.scene.id !== active.scene.id ? firstVideoRange : void 0 : void 0;
|
|
450
|
-
const preparedReadinessRange = posterPreparationRange ?? (contiguousNext && sceneHasVideoBackdrop(contiguousNext) ? contiguousNext : void 0);
|
|
451
434
|
const preparedPoster = posterPreparationRange && String(
|
|
452
435
|
posterPreparationRange.scene.variables.mediaPoster || ""
|
|
453
436
|
) ? {
|
|
@@ -471,7 +454,9 @@ function VideoFrame({
|
|
|
471
454
|
const blendProgress = previewingNext && blendDuration > 0 ? Math.round(clamp01((time - blendStart) / blendDuration) * 1e6) / 1e6 : 0;
|
|
472
455
|
const progress = rawProgress;
|
|
473
456
|
const isFinalScene = activeIndex === timeline.length - 1;
|
|
474
|
-
const
|
|
457
|
+
const presentsChapter = active.scene.templateId === "chapterTitle" || active.scene.templateId === "cinemaMedia" && (activeMediaFailed || !String(active.scene.variables.mediaUrl || "").trim());
|
|
458
|
+
const finalHold = presentsChapter ? 0.76 : activeTiming?.holdProgress;
|
|
459
|
+
const motionProgress = isFinalScene && finalHold !== void 0 ? Math.min(rawProgress, finalHold) : rawProgress;
|
|
475
460
|
const canvas = getDimensions(config.orientation);
|
|
476
461
|
const scale = Math.min(width / canvas.width, height / canvas.height);
|
|
477
462
|
const canvasLeft = (width - canvas.width * scale) / 2;
|
|
@@ -507,7 +492,6 @@ function VideoFrame({
|
|
|
507
492
|
onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene)) : void 0
|
|
508
493
|
}
|
|
509
494
|
),
|
|
510
|
-
preparedReadinessRange && /* @__PURE__ */ jsx2(PreparedSceneReadiness, { scene: preparedReadinessRange.scene }),
|
|
511
495
|
/* @__PURE__ */ jsxs(
|
|
512
496
|
"div",
|
|
513
497
|
{
|
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
|
|
6
6
|
// src/visual-system/scene-templates/chapter-title.tsx
|
|
7
7
|
import { jsx } from "react/jsx-runtime";
|
|
8
|
-
function TitleScene({ variables, width, height, progress }) {
|
|
8
|
+
function TitleScene({ variables, width, height, progress, motionProgress }) {
|
|
9
9
|
const title = String(variables.title ?? "A different perspective");
|
|
10
10
|
const unit = Math.min(width, height);
|
|
11
|
-
const
|
|
11
|
+
const presentation = motionProgress ?? progress;
|
|
12
|
+
const opacity = fade(presentation / 0.22) * (1 - fade((presentation - 0.76) / 0.24));
|
|
12
13
|
return /* @__PURE__ */ jsx("div", { "data-template": "title", "data-title-treatment": "quiet-fade", style: { position: "absolute", inset: 0, background: "#000", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsx("div", { "data-title-composition": "centered", style: { width: "76%", textAlign: "center", opacity, fontFamily: editorialFont, fontWeight: 500, fontSize: unit * 0.068, lineHeight: 1.18, letterSpacing: "-.025em", textWrap: "balance", overflowWrap: "anywhere" }, children: title }) });
|
|
13
14
|
}
|
|
14
15
|
var TitleSceneTemplate = TitleScene;
|
|
@@ -255,6 +255,7 @@ var SceneVideoBackdrop = ({
|
|
|
255
255
|
const [waitingKey, setWaitingKey] = useState();
|
|
256
256
|
const [exhaustedKey, setExhaustedKey] = useState();
|
|
257
257
|
const videoRef = useRef(null);
|
|
258
|
+
const presentedVideoUrl = useRef(void 0);
|
|
258
259
|
const startedVideoUrl = useRef(void 0);
|
|
259
260
|
const startedPlaybackId = useRef(void 0);
|
|
260
261
|
const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
|
|
@@ -274,6 +275,8 @@ var SceneVideoBackdrop = ({
|
|
|
274
275
|
}
|
|
275
276
|
const video = videoRef.current;
|
|
276
277
|
if (!video) return;
|
|
278
|
+
const expectedSource = video.getAttribute("src") === mediaUrl ? video.src : void 0;
|
|
279
|
+
let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;
|
|
277
280
|
let previousTime = video.currentTime;
|
|
278
281
|
let forwardFrames = 0;
|
|
279
282
|
let stopped = false;
|
|
@@ -281,8 +284,14 @@ var SceneVideoBackdrop = ({
|
|
|
281
284
|
let poll;
|
|
282
285
|
const observe = (_now, metadata) => {
|
|
283
286
|
if (stopped) return;
|
|
287
|
+
const currentSource = video.currentSrc === expectedSource;
|
|
288
|
+
if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {
|
|
289
|
+
awaitingFirstFrame = false;
|
|
290
|
+
clearTimeout(deadline);
|
|
291
|
+
deadline = setTimeout(fail, 1e3);
|
|
292
|
+
}
|
|
284
293
|
const time = metadata?.mediaTime ?? video.currentTime;
|
|
285
|
-
if (video.seeking || time < previousTime) forwardFrames = 0;
|
|
294
|
+
if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;
|
|
286
295
|
else if (time > previousTime + 1e-3) forwardFrames++;
|
|
287
296
|
previousTime = time;
|
|
288
297
|
if (forwardFrames >= 2) {
|
|
@@ -294,12 +303,13 @@ var SceneVideoBackdrop = ({
|
|
|
294
303
|
if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);
|
|
295
304
|
else poll = setTimeout(observe, 50);
|
|
296
305
|
};
|
|
297
|
-
const
|
|
306
|
+
const fail = () => {
|
|
298
307
|
if (!stopped) {
|
|
299
308
|
stopped = true;
|
|
300
309
|
unavailable();
|
|
301
310
|
}
|
|
302
|
-
}
|
|
311
|
+
};
|
|
312
|
+
let deadline = setTimeout(fail, awaitingFirstFrame ? 8e3 : 1e3);
|
|
303
313
|
observe();
|
|
304
314
|
return () => {
|
|
305
315
|
stopped = true;
|
|
@@ -330,6 +340,10 @@ var SceneVideoBackdrop = ({
|
|
|
330
340
|
video.setAttribute("src", mediaUrl);
|
|
331
341
|
video.load();
|
|
332
342
|
}
|
|
343
|
+
}, [mediaUrl]);
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
const video = videoRef.current;
|
|
346
|
+
if (!video) return;
|
|
333
347
|
return () => {
|
|
334
348
|
video.pause();
|
|
335
349
|
video.removeAttribute("src");
|
|
@@ -337,7 +351,7 @@ var SceneVideoBackdrop = ({
|
|
|
337
351
|
startedVideoUrl.current = void 0;
|
|
338
352
|
startedPlaybackId.current = void 0;
|
|
339
353
|
};
|
|
340
|
-
}, [
|
|
354
|
+
}, []);
|
|
341
355
|
useEffect(() => {
|
|
342
356
|
const video = videoRef.current;
|
|
343
357
|
if (video) video.volume = resolvedVolume;
|
|
@@ -450,7 +464,9 @@ var SceneVideoBackdrop = ({
|
|
|
450
464
|
onLoadedData: (event) => {
|
|
451
465
|
const video = event.currentTarget;
|
|
452
466
|
const markPresented = () => {
|
|
453
|
-
if (!video.isConnected) return;
|
|
467
|
+
if (!video.isConnected || presentationRef.current.key !== videoPresentationKey || video.getAttribute("src") !== mediaUrl || video.currentSrc !== video.src) return;
|
|
468
|
+
presentedVideoUrl.current = mediaUrl;
|
|
469
|
+
video.dispatchEvent(new Event("vanillasky:video-frame-presented", { bubbles: true }));
|
|
454
470
|
onReady?.();
|
|
455
471
|
if (!retainPoster) setDecodedVideoUrl(mediaUrl);
|
|
456
472
|
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MediaScene,
|
|
3
3
|
MediaSceneTemplate
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-PMJNBR4F.js";
|
|
5
|
+
import "./chunk-X4RGAEL5.js";
|
|
6
|
+
import "./chunk-OBRKB7PL.js";
|
|
7
7
|
import "./chunk-224QNWRA.js";
|
|
8
8
|
import "./chunk-OOBT4X46.js";
|
|
9
9
|
export {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EditorialSurface
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-MKMCX3AF.js";
|
|
4
|
+
import "./chunk-X4RGAEL5.js";
|
|
5
|
+
import "./chunk-OBRKB7PL.js";
|
|
6
6
|
import {
|
|
7
7
|
fade
|
|
8
8
|
} from "./chunk-4YM2M62S.js";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MediaScene
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-PMJNBR4F.js";
|
|
4
|
+
import "./chunk-X4RGAEL5.js";
|
|
5
5
|
import {
|
|
6
6
|
spring
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-OBRKB7PL.js";
|
|
8
8
|
import {
|
|
9
9
|
editorialFont
|
|
10
10
|
} from "./chunk-4YM2M62S.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EditorialSurface
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-MKMCX3AF.js";
|
|
4
|
+
import "./chunk-X4RGAEL5.js";
|
|
5
|
+
import "./chunk-OBRKB7PL.js";
|
|
6
6
|
import {
|
|
7
7
|
fade
|
|
8
8
|
} from "./chunk-4YM2M62S.js";
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
TitleSceneTemplate
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-7AA2JWHZ.js";
|
|
4
4
|
import "./chunk-4YM2M62S.js";
|
|
5
5
|
import {
|
|
6
6
|
getSceneDuration,
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
VideoFrame,
|
|
41
41
|
getDimensions,
|
|
42
42
|
sceneReadinessKey
|
|
43
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-24TCMDAA.js";
|
|
44
44
|
import "./chunk-224QNWRA.js";
|
|
45
45
|
import "./chunk-OOBT4X46.js";
|
|
46
46
|
import {
|
|
@@ -94,13 +94,13 @@ import { createElement } from "react";
|
|
|
94
94
|
|
|
95
95
|
// src/visual-system/catalog/builtin-loaders.generated.ts
|
|
96
96
|
var GENERATED_BUILTIN_TEMPLATE_LOADERS = {
|
|
97
|
-
"cinemaMedia": () => import("./cinema-media-
|
|
98
|
-
"chapterTitle": () => import("./chapter-title-
|
|
99
|
-
"editorialTimeline": () => import("./editorial-timeline-
|
|
100
|
-
"mobileMessage": () => import("./mobile-message-
|
|
101
|
-
"comparison": () => import("./comparison-
|
|
102
|
-
"quote": () => import("./quote-
|
|
103
|
-
"keyFigure": () => import("./key-figure-
|
|
97
|
+
"cinemaMedia": () => import("./cinema-media-BVQFZL3A.js").then((module) => ({ default: module.MediaSceneTemplate })),
|
|
98
|
+
"chapterTitle": () => import("./chapter-title-2JVDU62E.js").then((module) => ({ default: module.TitleSceneTemplate })),
|
|
99
|
+
"editorialTimeline": () => import("./editorial-timeline-JADHHNEV.js").then((module) => ({ default: module.TimelineSceneTemplate })),
|
|
100
|
+
"mobileMessage": () => import("./mobile-message-GADX632R.js").then((module) => ({ default: module.NotificationSceneTemplate })),
|
|
101
|
+
"comparison": () => import("./comparison-T27C7FSV.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
|
|
102
|
+
"quote": () => import("./quote-FRCEC34C.js").then((module) => ({ default: module.QuoteSceneTemplate })),
|
|
103
|
+
"keyFigure": () => import("./key-figure-TZQWPFMI.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
// src/visual-system/catalog/builtin-player.generated.ts
|
|
@@ -534,7 +534,6 @@ function usePlaybackClock({
|
|
|
534
534
|
loopRef,
|
|
535
535
|
sceneIndexRef,
|
|
536
536
|
visualReadyRef,
|
|
537
|
-
posterBridgeKeysRef,
|
|
538
537
|
callbacksRef,
|
|
539
538
|
setCurrentTime,
|
|
540
539
|
setIsPlaying
|
|
@@ -545,6 +544,10 @@ function usePlaybackClock({
|
|
|
545
544
|
let onsetWaitSeconds = 0;
|
|
546
545
|
let clockWaitSeconds = 0;
|
|
547
546
|
let lastNarrationTime;
|
|
547
|
+
let committedTime = timeRef.current;
|
|
548
|
+
let groupHandoff;
|
|
549
|
+
let requestId = stateRef.current.requestId;
|
|
550
|
+
let runId = stateRef.current.runId;
|
|
548
551
|
const failNarration = (error, state) => {
|
|
549
552
|
setIsPlaying(false);
|
|
550
553
|
try {
|
|
@@ -565,6 +568,12 @@ function usePlaybackClock({
|
|
|
565
568
|
const tick = (now) => {
|
|
566
569
|
const current = stateRef.current;
|
|
567
570
|
const config = current.config;
|
|
571
|
+
const externallySeeked = timeRef.current !== committedTime;
|
|
572
|
+
const replaced = current.requestId !== requestId || current.runId !== runId;
|
|
573
|
+
requestId = current.requestId;
|
|
574
|
+
runId = current.runId;
|
|
575
|
+
if (externallySeeked || replaced) groupHandoff = void 0;
|
|
576
|
+
let deferGroupStall = false;
|
|
568
577
|
const elapsed = Math.max(0, (now - previous) / 1e3);
|
|
569
578
|
let narrationReady = true;
|
|
570
579
|
try {
|
|
@@ -599,6 +608,7 @@ function usePlaybackClock({
|
|
|
599
608
|
return;
|
|
600
609
|
}
|
|
601
610
|
clockWaitSeconds = narrationTime !== void 0 && narrationTime === lastNarrationTime && narrationReady && !stalled ? clockWaitSeconds + elapsed : 0;
|
|
611
|
+
const audioMovedBackwards = narrationTime !== void 0 && lastNarrationTime !== void 0 && narrationTime < lastNarrationTime;
|
|
602
612
|
lastNarrationTime = narrationTime;
|
|
603
613
|
if (clockWaitSeconds >= 8) {
|
|
604
614
|
failNarration(new Error("Narration audio clock did not advance within eight seconds"), current);
|
|
@@ -613,8 +623,18 @@ function usePlaybackClock({
|
|
|
613
623
|
nextTime = Math.min(raw, duration2);
|
|
614
624
|
}
|
|
615
625
|
const target = ranges.find((range) => nextTime >= range.start && nextTime < range.end) ?? ranges.at(-1);
|
|
616
|
-
const waitingForVisual = Boolean(target && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(target.scene)
|
|
617
|
-
if (waitingForVisual && target)
|
|
626
|
+
const waitingForVisual = Boolean(target && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(target.scene));
|
|
627
|
+
if (waitingForVisual && target) {
|
|
628
|
+
nextTime = target.start;
|
|
629
|
+
const fromGroup = cued?.scene.narrationGroup;
|
|
630
|
+
const toGroup = target.scene.narrationGroup;
|
|
631
|
+
const continuesParagraph = !externallySeeked && !replaced && !audioMovedBackwards && narrationReady && narrationTime !== void 0 && fromGroup && toGroup && fromGroup.id === toGroup.id && fromGroup.text === toGroup.text && target === ranges[sceneIndexRef.current + 1] && narrationTime > fromGroup.offsetSeconds + 0.04;
|
|
632
|
+
if (continuesParagraph) {
|
|
633
|
+
const key = `${sceneReadinessKey(cued.scene)}\0${sceneReadinessKey(target.scene)}\0${fromGroup.id}\0${fromGroup.text}`;
|
|
634
|
+
if (groupHandoff?.key !== key) groupHandoff = { key, startedAt: now };
|
|
635
|
+
deferGroupStall = !stalled && now - groupHandoff.startedAt < 200;
|
|
636
|
+
} else groupHandoff = void 0;
|
|
637
|
+
} else groupHandoff = void 0;
|
|
618
638
|
if (nextTime !== timeRef.current) {
|
|
619
639
|
timeRef.current = nextTime;
|
|
620
640
|
setCurrentTime(nextTime);
|
|
@@ -645,7 +665,8 @@ function usePlaybackClock({
|
|
|
645
665
|
}
|
|
646
666
|
const duration = current.config ? getVideoDuration(current.config) : 0;
|
|
647
667
|
const active = current.config ? resolveVideoTimeline(current.config).find((range) => timeRef.current >= range.start && timeRef.current < range.end) : void 0;
|
|
648
|
-
reportStall(Boolean(active && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(active.scene) && !
|
|
668
|
+
reportStall(Boolean(active && visualReadyRef && visualReadyRef.current !== sceneReadinessKey(active.scene) && !deferGroupStall) || !settled && Boolean(current.config?.scenes.length) && duration > 0 && timeRef.current >= duration);
|
|
669
|
+
committedTime = timeRef.current;
|
|
649
670
|
if (!settled || looping || timeRef.current < duration) frame = requestAnimationFrame(tick);
|
|
650
671
|
else setIsPlaying(false);
|
|
651
672
|
};
|
|
@@ -732,13 +753,8 @@ function VideoPlayerRuntime({
|
|
|
732
753
|
const loopRef = useRef(loop);
|
|
733
754
|
const sceneIndexRef = useRef(-1);
|
|
734
755
|
const mediaFrameReportedRef = useRef(false);
|
|
735
|
-
const posterBridgeKeysRef = useRef(/* @__PURE__ */ new Set());
|
|
736
756
|
const visualReadyRef = useRef(void 0);
|
|
737
|
-
const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false
|
|
738
|
-
if (posterBridge) {
|
|
739
|
-
posterBridgeKeysRef.current.add(key);
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
757
|
+
const reportVisualReady = useMemo(() => (key, error, actualVideoFrame = false) => {
|
|
742
758
|
if (error) {
|
|
743
759
|
setIsPlaying(false);
|
|
744
760
|
callbacksRef.current.onError?.(error, stateRef.current);
|
|
@@ -798,7 +814,6 @@ function VideoPlayerRuntime({
|
|
|
798
814
|
setActiveStream(stream);
|
|
799
815
|
setActiveSavedVideo(video);
|
|
800
816
|
mediaFrameReportedRef.current = false;
|
|
801
|
-
posterBridgeKeysRef.current.clear();
|
|
802
817
|
sceneIndexRef.current = -1;
|
|
803
818
|
setReplacementPending(stream != null);
|
|
804
819
|
setState(video ? savedVideoState(video) : createVideoState());
|
|
@@ -917,7 +932,6 @@ function VideoPlayerRuntime({
|
|
|
917
932
|
loopRef,
|
|
918
933
|
sceneIndexRef,
|
|
919
934
|
visualReadyRef,
|
|
920
|
-
posterBridgeKeysRef,
|
|
921
935
|
callbacksRef,
|
|
922
936
|
setCurrentTime,
|
|
923
937
|
setIsPlaying
|
|
@@ -1537,6 +1551,7 @@ async function prepareSceneMedia(variables, signal) {
|
|
|
1537
1551
|
var DEFAULT_MAX_CACHED_LINES = 60;
|
|
1538
1552
|
var SPEECH_PREPARATION_TIMEOUT_MS = 3e3;
|
|
1539
1553
|
var FALLBACK_BITS_PER_SECOND = 128e3;
|
|
1554
|
+
var ACTIVATION_AUDIO = "data:audio/wav;base64,UklGRrQBAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YZABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
1540
1555
|
var sharedContext;
|
|
1541
1556
|
function actionEndpoint(endpoint, action) {
|
|
1542
1557
|
const value = String(endpoint);
|
|
@@ -1689,6 +1704,14 @@ function createVideoChatVoice(options = {}) {
|
|
|
1689
1704
|
},
|
|
1690
1705
|
resume() {
|
|
1691
1706
|
held = false;
|
|
1707
|
+
if (!disposed && !silent && !generatedElement && globalThis.navigator?.userActivation?.isActive) {
|
|
1708
|
+
const element = generatedElement = new Audio();
|
|
1709
|
+
element.src = ACTIVATION_AUDIO;
|
|
1710
|
+
try {
|
|
1711
|
+
void element.play().catch(() => void 0);
|
|
1712
|
+
} catch {
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1692
1715
|
if (sounding) {
|
|
1693
1716
|
const fail = playbackFailure;
|
|
1694
1717
|
void sounding.play().catch(() => {
|
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
|
|
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
|
|
905
|
-
subject
|
|
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/docs/development.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Run `npm ci --no-audit`, then `npm run dev:chat`. The localhost HMR surface renders the actual SDK `VideoChat`, templates and server handler from source. It never loads provider credentials. Select an intent and fault condition in the development toolbar; use the chat's normal media-mode controls. The prompt box remains the real product UI.
|
|
4
4
|
|
|
5
|
-
Offline answers are deterministic for explanation, story, comedy, imagination, practical steps and golf. They use local waterfall footage and
|
|
5
|
+
Offline answers are deterministic for explanation, story, comedy, imagination, practical steps and golf. They use local waterfall footage and locally synthesized speech matching each displayed opening, body and ending. This harness lets you hear complete narration while checking loading, timing, controls and recovery without AI credits. The waterfall remains generic fixture footage; it cannot prove generated answer quality or visual relevance. See [spoken fixture provenance](https://github.com/VanillaSkyAi/video/blob/main/dev/chat/speech/README.md) for transcripts and regeneration commands. Conditions cover ready, delayed footage, missing media, decode failure, speech failure, exhausted video allowance and request throttling. Browser speech may be used in the speech-failure condition.
|
|
6
6
|
|
|
7
7
|
The toolbar labels source and fixture identity. It separates the first body surface from the first decoded moving-footage frame; the former is a renderer paint opportunity and can precede decode. Its bounded safe phase log records browser request/stream arrival phases, speech response completion, first speech, footage and buffer pauses. No prompts, narration, scene IDs or provider bodies are retained. Media start/end/skip reasons come from the handler’s separate host-only `onDiagnostic` callback, shown in the local terminal for offline fixtures; live hosts own that callback themselves. Stream arrival is not the server’s exact authorship timestamp. Local fixtures never fetch external footage or invoke a paid model. Mode boundaries, provider deadlines and host admission remain covered by their dedicated server and host suites.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -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 { 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 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 = () => {\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 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(); } };\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 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 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 || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return;\n presentedVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReady?.();\n if (!retainPoster) setDecodedVideoUrl(mediaUrl);\n };\n if (video.requestVideoFrameCallback) {\n video.requestVideoFrameCallback(markPresented);\n return;\n }\n markPresented();\n }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop={persistent ? \"persistent\" : \"scene\"}\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"path": "src/visual-system/scene-templates/color-utils.ts",
|
|
@@ -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(
|
|
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",
|
|
@@ -8,6 +8,8 @@ interface StockVideo {
|
|
|
8
8
|
interface PexelsVideo {
|
|
9
9
|
url?: string;
|
|
10
10
|
image?: string;
|
|
11
|
+
title?: unknown;
|
|
12
|
+
tags?: unknown;
|
|
11
13
|
video_files?: { link?: string; width?: number; height?: number; file_type?: string }[];
|
|
12
14
|
}
|
|
13
15
|
const cache = new Map<string, { expires: number; media: StockVideo | null }>();
|
|
@@ -21,7 +23,7 @@ function pexelsUrl(value: unknown): value is string {
|
|
|
21
23
|
} catch { return false; }
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
/** Full catalog search.
|
|
26
|
+
/** Full catalog search. Available metadata ranks subject relevance, not factual proof.
|
|
25
27
|
* Applications using this adapter must display a prominent link to Pexels.
|
|
26
28
|
* https://www.pexels.com/api/documentation/#guidelines
|
|
27
29
|
*/
|
|
@@ -41,13 +43,18 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
|
|
|
41
43
|
if (!response.ok) return null;
|
|
42
44
|
const result = await response.json() as { videos?: PexelsVideo[] };
|
|
43
45
|
signal.throwIfAborted();
|
|
44
|
-
let selected: StockVideo | null = null, bestScore =
|
|
46
|
+
let selected: StockVideo | null = null, bestScore = -1;
|
|
45
47
|
for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
|
|
46
48
|
if (!pexelsUrl(video.url)) continue;
|
|
47
|
-
const
|
|
49
|
+
const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
|
|
50
|
+
const title = typeof video.title === "string" ? video.title : "";
|
|
51
|
+
const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
|
|
52
|
+
const subject = words(`${slug} ${title} ${tags}`).filter(token => !/^\d+$/.test(token));
|
|
48
53
|
const matches = tokens.filter(token => subject.includes(token)).length;
|
|
49
|
-
//
|
|
50
|
-
|
|
54
|
+
// The documented Video resource can have only a numeric page URL and no
|
|
55
|
+
// editorial metadata. Preserve provider search order for unknown relevance;
|
|
56
|
+
// positive overlap ranks above it, while explicitly unrelated copy is skipped.
|
|
57
|
+
if (subject.length > 0 && matches === 0) continue;
|
|
51
58
|
const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
|
|
52
59
|
file.file_type === "video/mp4" && pexelsUrl(file.link)
|
|
53
60
|
&& Number.isFinite(file.width) && Number.isFinite(file.height)
|