@vanillaskyai/video 0.10.10 → 0.10.12
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 +10 -0
- package/dist/check-runtime.js +1 -1
- package/dist/{chunk-XL3H42K5.js → chunk-HFVNAPHZ.js} +1 -1
- package/dist/{chunk-FFKAHNYD.js → chunk-MXLVTOGR.js} +140 -37
- package/dist/{chunk-3S5FXE6G.js → chunk-QSBDB4J2.js} +1 -1
- package/dist/{chunk-YERSNTFL.js → chunk-YAHT3LST.js} +6 -5
- package/dist/{cinema-media-IAK3LC2J.js → cinema-media-HYCDG65Z.js} +2 -2
- package/dist/{comparison-MCTN376M.js → comparison-XXAP4S4J.js} +2 -2
- package/dist/{editorial-timeline-435UCJ45.js → editorial-timeline-VUQC6KPA.js} +2 -2
- package/dist/{key-figure-BSOQ3TYN.js → key-figure-4TJK7HLT.js} +2 -2
- package/dist/{mobile-message-GVFA56QP.js → mobile-message-FIHAO6XC.js} +2 -2
- package/dist/{quote-H2X4XGPB.js → quote-GRPIYKQJ.js} +2 -2
- package/dist/react.js +45 -12
- package/package.json +1 -1
- package/registry/items/backgrounds.json +1 -1
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/stock.ts +3 -1
- package/styles/video-chat.css +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.10.12
|
|
8
|
+
|
|
9
|
+
- Use query context to choose between equally relevant Pexels subject matches in the starter, while preserving subject, activity and equipment priority.
|
|
10
|
+
|
|
11
|
+
## 0.10.11
|
|
12
|
+
|
|
13
|
+
- Keep the opening chapter visible until the first body visual and narration are ready, without mounting a second player or replaying the opening.
|
|
14
|
+
- Keep outgoing silent footage moving, or a chapter readable, while the next clip becomes playable; retain bounded chapter recovery, pause/cancel behavior and two-video preparation.
|
|
15
|
+
- Recognize sustained native video motion, reuse fresh readiness across scene promotion, and preserve prepared footage during narration holds instead of rewinding it. Let silent clips loop natively within their finite scene.
|
|
16
|
+
|
|
7
17
|
## 0.10.10
|
|
8
18
|
|
|
9
19
|
- Accept multiline streamed chat JSON objects without waiting for the entire answer, while retaining bounded parsing and strict scene validation.
|
package/dist/check-runtime.js
CHANGED
|
@@ -21,28 +21,53 @@ import { createContext, useContext, useEffect, useRef } from "react";
|
|
|
21
21
|
import { jsx } from "react/jsx-runtime";
|
|
22
22
|
var MountedReadinessContext = createContext(void 0);
|
|
23
23
|
var sceneReadinessKey = (scene) => `${scene.id}\0${String(scene.variables.mediaUrl || "")}`;
|
|
24
|
-
function MountedSceneReadiness({
|
|
24
|
+
function MountedSceneReadiness({
|
|
25
|
+
scene,
|
|
26
|
+
playing,
|
|
27
|
+
fallback = false,
|
|
28
|
+
onFailure,
|
|
29
|
+
onReady,
|
|
30
|
+
observeIncoming = false,
|
|
31
|
+
timeoutMs = 8e3,
|
|
32
|
+
preparedProof
|
|
33
|
+
}) {
|
|
25
34
|
const marker = useRef(null);
|
|
26
35
|
const report = useContext(MountedReadinessContext);
|
|
27
36
|
const key = sceneReadinessKey(scene);
|
|
28
37
|
const onFailureRef = useRef(onFailure);
|
|
29
38
|
onFailureRef.current = onFailure;
|
|
39
|
+
const onReadyRef = useRef(onReady);
|
|
40
|
+
onReadyRef.current = onReady;
|
|
30
41
|
useEffect(() => {
|
|
31
|
-
if (!report || !playing) return;
|
|
42
|
+
if (!report && !onReadyRef.current || !playing) return;
|
|
32
43
|
let stopped = false;
|
|
33
44
|
let frame = 0;
|
|
34
45
|
let callback;
|
|
35
46
|
let observed;
|
|
36
47
|
let presented;
|
|
48
|
+
let previousMediaTime;
|
|
49
|
+
let forwardFrames = 0;
|
|
50
|
+
let motionRevision = 0;
|
|
37
51
|
const root = marker.current?.closest("[data-video-frame]");
|
|
38
52
|
const start = performance.now();
|
|
39
53
|
const finish = (error, actualVideoFrame = false) => {
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
stopped = true;
|
|
56
|
+
if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
|
|
57
|
+
if (error && onFailureRef.current) onFailureRef.current();
|
|
58
|
+
else if (onReadyRef.current && !error) {
|
|
59
|
+
const video = presented;
|
|
60
|
+
const mediaTime = video?.currentTime ?? 0;
|
|
61
|
+
const confirmedAt = performance.now();
|
|
62
|
+
const source = video?.currentSrc;
|
|
63
|
+
const revision = motionRevision;
|
|
64
|
+
let consumed = false;
|
|
65
|
+
onReadyRef.current(actualVideoFrame && video ? { consume: (candidate) => {
|
|
66
|
+
const valid = !consumed && candidate === video && video.isConnected && motionRevision === revision && !video.paused && !video.seeking && video.currentSrc === source && video.currentSrc === video.src && performance.now() - confirmedAt <= 200 && video.currentTime >= mediaTime && video.currentTime - mediaTime <= 0.2;
|
|
67
|
+
consumed = true;
|
|
68
|
+
return valid;
|
|
69
|
+
} } : void 0);
|
|
70
|
+
} else report?.(key, error, actualVideoFrame);
|
|
46
71
|
};
|
|
47
72
|
const check = () => {
|
|
48
73
|
if (stopped) return;
|
|
@@ -50,7 +75,7 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
50
75
|
finish();
|
|
51
76
|
return;
|
|
52
77
|
}
|
|
53
|
-
const layer = root?.querySelector('[data-scene-layer="active"]');
|
|
78
|
+
const layer = observeIncoming ? [...root?.querySelectorAll("[data-layer-scene-id]") ?? []].find((node) => node.getAttribute("data-layer-scene-id") === scene.id) : root?.querySelector('[data-scene-layer="active"]');
|
|
54
79
|
const loading = layer?.querySelector("[data-template-loading]");
|
|
55
80
|
const mediaUrl = String(scene.variables.mediaUrl || "");
|
|
56
81
|
const isVideo = scene.variables.mediaType === "video" || /\.(mp4|webm|mov)(?:[?#]|$)/i.test(mediaUrl);
|
|
@@ -61,22 +86,38 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
61
86
|
}
|
|
62
87
|
if (isVideo) {
|
|
63
88
|
const video = layer.querySelector("video");
|
|
64
|
-
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.
|
|
65
|
-
if (
|
|
89
|
+
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
90
|
+
if (preparedProof?.consume(video)) {
|
|
91
|
+
finish(void 0, true);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (observed !== video) {
|
|
95
|
+
previousMediaTime = void 0;
|
|
96
|
+
forwardFrames = 0;
|
|
97
|
+
}
|
|
98
|
+
if (presented === video && (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA || forwardFrames >= 2)) {
|
|
66
99
|
finish(void 0, true);
|
|
67
100
|
return;
|
|
68
101
|
}
|
|
69
102
|
observed = video;
|
|
70
103
|
if (video.requestVideoFrameCallback) {
|
|
71
|
-
callback = video.requestVideoFrameCallback(() => {
|
|
104
|
+
callback = video.requestVideoFrameCallback((_now, metadata) => {
|
|
72
105
|
callback = void 0;
|
|
73
|
-
|
|
106
|
+
if (stopped) return;
|
|
107
|
+
const sameSource = video.isConnected && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src;
|
|
108
|
+
const mediaTime = metadata.mediaTime;
|
|
109
|
+
if (!sameSource || video.paused || video.seeking || !Number.isFinite(mediaTime) || previousMediaTime !== void 0 && mediaTime <= previousMediaTime + 1e-3) forwardFrames = 0;
|
|
110
|
+
else if (previousMediaTime !== void 0 && mediaTime > previousMediaTime + 1e-3) forwardFrames++;
|
|
111
|
+
previousMediaTime = sameSource && !video.paused && !video.seeking ? mediaTime : void 0;
|
|
112
|
+
if (sameSource) presented = video;
|
|
74
113
|
check();
|
|
75
114
|
});
|
|
76
115
|
return;
|
|
77
116
|
}
|
|
78
|
-
|
|
79
|
-
|
|
117
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
118
|
+
finish(void 0, true);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
80
121
|
}
|
|
81
122
|
} else {
|
|
82
123
|
const image = [...layer.querySelectorAll("img") ?? []].find((element) => element.getAttribute("src") === mediaUrl);
|
|
@@ -86,7 +127,7 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
86
127
|
}
|
|
87
128
|
}
|
|
88
129
|
}
|
|
89
|
-
if (performance.now() - start >=
|
|
130
|
+
if (timeoutMs !== null && performance.now() - start >= timeoutMs) {
|
|
90
131
|
finish(new Error("Scene media did not become ready"));
|
|
91
132
|
return;
|
|
92
133
|
}
|
|
@@ -101,17 +142,26 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
101
142
|
callback = void 0;
|
|
102
143
|
check();
|
|
103
144
|
};
|
|
145
|
+
const resetMotion = (event) => {
|
|
146
|
+
if (event.target === observed || event.target === presented) {
|
|
147
|
+
motionRevision++;
|
|
148
|
+
previousMediaTime = void 0;
|
|
149
|
+
forwardFrames = 0;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
for (const type of ["pause", "waiting", "seeking"]) root?.addEventListener(type, resetMotion, true);
|
|
104
153
|
root?.addEventListener("vanillasky:video-frame-presented", onPresented);
|
|
105
154
|
check();
|
|
106
|
-
const timeout = setTimeout(() => finish(new Error("Scene media did not become ready")),
|
|
155
|
+
const timeout = timeoutMs === null ? void 0 : setTimeout(() => finish(new Error("Scene media did not become ready")), timeoutMs);
|
|
107
156
|
return () => {
|
|
108
157
|
root?.removeEventListener("vanillasky:video-frame-presented", onPresented);
|
|
158
|
+
for (const type of ["pause", "waiting", "seeking"]) root?.removeEventListener(type, resetMotion, true);
|
|
109
159
|
stopped = true;
|
|
110
160
|
clearTimeout(timeout);
|
|
111
161
|
cancelAnimationFrame(frame);
|
|
112
162
|
if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
|
|
113
163
|
};
|
|
114
|
-
}, [key, report, scene, playing, fallback]);
|
|
164
|
+
}, [key, report, scene, playing, fallback, observeIncoming, timeoutMs, preparedProof]);
|
|
115
165
|
return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
|
|
116
166
|
}
|
|
117
167
|
|
|
@@ -356,6 +406,12 @@ function VideoFrame({
|
|
|
356
406
|
style
|
|
357
407
|
}) {
|
|
358
408
|
const recoveryRoot = useRef2(null);
|
|
409
|
+
const displayedKey = useRef2(void 0);
|
|
410
|
+
const displayedWasPlaying = useRef2(false);
|
|
411
|
+
const handoffProof = useRef2(void 0);
|
|
412
|
+
const confirmedHandoff = useRef2(void 0);
|
|
413
|
+
const [preparedMedia, setPreparedMedia] = useState(() => /* @__PURE__ */ new Set());
|
|
414
|
+
const markPrepared = useCallback((key) => setPreparedMedia((previous2) => /* @__PURE__ */ new Set([...previous2, key])), []);
|
|
359
415
|
const reportedFailures = useRef2(/* @__PURE__ */ new Set());
|
|
360
416
|
const [failedMedia, setFailedMedia] = useState(() => /* @__PURE__ */ new Set());
|
|
361
417
|
const markMediaFailed = useCallback((key, reason = "playback-error") => {
|
|
@@ -364,14 +420,18 @@ function VideoFrame({
|
|
|
364
420
|
reportedFailures.current.add(key);
|
|
365
421
|
recoveryRoot.current?.dispatchEvent(new CustomEvent("vanillasky:media-recovery", { bubbles: true, detail: { reason: ["decode-error", "frame-readiness-timeout", "stalled-media", "playback-error"].includes(reason) ? reason : "playback-error" } }));
|
|
366
422
|
}
|
|
367
|
-
setFailedMedia((
|
|
423
|
+
setFailedMedia((previous2) => previous2.has(key) ? previous2 : /* @__PURE__ */ new Set([...previous2, key]));
|
|
368
424
|
}, [config.scenes]);
|
|
369
425
|
useEffect2(() => {
|
|
370
426
|
const currentKeys = new Set(config.scenes.map(sceneReadinessKey));
|
|
427
|
+
setPreparedMedia((previous2) => {
|
|
428
|
+
const retained = new Set([...previous2].filter((key) => currentKeys.has(key)));
|
|
429
|
+
return retained.size === previous2.size ? previous2 : retained;
|
|
430
|
+
});
|
|
371
431
|
for (const key of reportedFailures.current) if (!currentKeys.has(key)) reportedFailures.current.delete(key);
|
|
372
|
-
setFailedMedia((
|
|
373
|
-
const retained = new Set([...
|
|
374
|
-
return retained.size ===
|
|
432
|
+
setFailedMedia((previous2) => {
|
|
433
|
+
const retained = new Set([...previous2].filter((key) => currentKeys.has(key)));
|
|
434
|
+
return retained.size === previous2.size ? previous2 : retained;
|
|
375
435
|
});
|
|
376
436
|
}, [config.scenes]);
|
|
377
437
|
const decoderConstrainedDevice = useDecoderConstraint();
|
|
@@ -379,8 +439,29 @@ function VideoFrame({
|
|
|
379
439
|
const lastRange = timeline.at(-1);
|
|
380
440
|
const foundIndex = timeline.findIndex((range) => time >= range.start && time < range.end);
|
|
381
441
|
const afterEnd = lastRange && time >= lastRange.end;
|
|
382
|
-
const
|
|
383
|
-
const
|
|
442
|
+
const targetIndex = foundIndex >= 0 ? foundIndex : afterEnd ? timeline.length - 1 : -1;
|
|
443
|
+
const target = timeline[targetIndex];
|
|
444
|
+
const targetKey = target ? sceneReadinessKey(target.scene) : void 0;
|
|
445
|
+
if (confirmedHandoff.current !== targetKey || !(playing || preparingNarration)) {
|
|
446
|
+
confirmedHandoff.current = void 0;
|
|
447
|
+
handoffProof.current = void 0;
|
|
448
|
+
}
|
|
449
|
+
const previousIndex = timeline.findIndex((range) => sceneReadinessKey(range.scene) === displayedKey.current);
|
|
450
|
+
const previous = timeline[previousIndex];
|
|
451
|
+
const canPrepare = (range) => Boolean(range && mediaAudioMuted && sceneHasVideoBackdrop(range) && supportsExternalVideoBackdrop(kit.getTemplate(range.scene.templateId)));
|
|
452
|
+
const canRetain = (range) => canPrepare(range) || range?.scene.templateId === "chapterTitle";
|
|
453
|
+
const hasPlayableMedia = (range) => {
|
|
454
|
+
if (!range || !preparedMedia.has(sceneReadinessKey(range.scene))) return false;
|
|
455
|
+
const layer = [...recoveryRoot.current?.querySelectorAll("[data-layer-scene-id]") ?? []].find((node) => node.getAttribute("data-layer-scene-id") === range.scene.id);
|
|
456
|
+
const video = layer?.querySelector("video");
|
|
457
|
+
return Boolean(video && video.getAttribute("src") === range.scene.variables.mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA);
|
|
458
|
+
};
|
|
459
|
+
const handoffPending = Boolean(displayedWasPlaying.current && target && previous && targetIndex === previousIndex + 1 && time <= target.start + 1e-3 && rangesAreContiguous(previous, target) && canRetain(previous) && canPrepare(target) && !failedMedia.has(sceneReadinessKey(target.scene)) && !hasPlayableMedia(target) && confirmedHandoff.current !== targetKey);
|
|
460
|
+
const activeIndex = handoffPending ? previousIndex : targetIndex;
|
|
461
|
+
const active = timeline[activeIndex];
|
|
462
|
+
const displayKey = active ? sceneReadinessKey(active.scene) : void 0;
|
|
463
|
+
displayedWasPlaying.current = displayedKey.current === displayKey && displayedWasPlaying.current || playing || preparingNarration;
|
|
464
|
+
displayedKey.current = displayKey;
|
|
384
465
|
if (!active) {
|
|
385
466
|
return /* @__PURE__ */ jsx2(
|
|
386
467
|
"div",
|
|
@@ -432,18 +513,21 @@ function VideoFrame({
|
|
|
432
513
|
contiguousNext && decoderConstrainedDevice && !boundedPreparation && sceneHasVideoBackdrop(active) && sceneHasVideoBackdrop(contiguousNext)
|
|
433
514
|
);
|
|
434
515
|
const activeMediaFailed = failedMedia.has(sceneReadinessKey(active.scene));
|
|
435
|
-
const
|
|
516
|
+
const preparingNext = canRetain(active) && canPrepare(contiguousNext);
|
|
517
|
+
const nextPlayable = hasPlayableMedia(contiguousNext);
|
|
518
|
+
const mountingNext = handoffPending || Boolean(
|
|
436
519
|
contiguousNext && !decoderConstrainedTransition && (boundedPreparation || time >= prerollStart) && time < blendEnd && (eligibleNextTransition || prerollsNext || boundedPreparation && sceneHasVideoBackdrop(contiguousNext))
|
|
437
520
|
);
|
|
438
521
|
const previewingNext = Boolean(
|
|
439
|
-
eligibleNextTransition && time >= blendStart && time < blendEnd
|
|
522
|
+
eligibleNextTransition && time >= blendStart && time < blendEnd && (!preparingNext || nextPlayable)
|
|
440
523
|
);
|
|
441
524
|
const blendProgress = previewingNext && blendDuration > 0 ? Math.round(clamp01((time - blendStart) / blendDuration) * 1e6) / 1e6 : 0;
|
|
442
525
|
const progress = rawProgress;
|
|
443
526
|
const isFinalScene = activeIndex === timeline.length - 1;
|
|
444
527
|
const presentsChapter = active.scene.templateId === "chapterTitle" || active.scene.templateId === "cinemaMedia" && (activeMediaFailed || !String(active.scene.variables.mediaUrl || "").trim());
|
|
445
528
|
const finalHold = presentsChapter ? 0.76 : activeTiming?.holdProgress;
|
|
446
|
-
const
|
|
529
|
+
const holdChapter = presentsChapter && preparingNext && !nextPlayable;
|
|
530
|
+
const motionProgress = (isFinalScene || holdChapter) && finalHold !== void 0 ? Math.min(rawProgress, finalHold) : rawProgress;
|
|
447
531
|
const canvas = getDimensions(config.orientation);
|
|
448
532
|
const scale = Math.min(width / canvas.width, height / canvas.height);
|
|
449
533
|
const canvasLeft = (width - canvas.width * scale) / 2;
|
|
@@ -453,12 +537,12 @@ function VideoFrame({
|
|
|
453
537
|
{
|
|
454
538
|
ref: recoveryRoot,
|
|
455
539
|
onErrorCapture: (event) => {
|
|
456
|
-
const
|
|
457
|
-
if (!(
|
|
458
|
-
const ownerId =
|
|
540
|
+
const target2 = event.target;
|
|
541
|
+
if (!(target2 instanceof HTMLVideoElement || target2 instanceof HTMLImageElement)) return;
|
|
542
|
+
const ownerId = target2.closest("[data-layer-scene-id]")?.getAttribute("data-layer-scene-id");
|
|
459
543
|
const owner = [active, contiguousNext].find((range) => range?.scene.id === ownerId);
|
|
460
544
|
const template = owner && kit.getTemplate(owner.scene.templateId);
|
|
461
|
-
if (owner && template && supportsExternalVideoBackdrop(template) &&
|
|
545
|
+
if (owner && template && supportsExternalVideoBackdrop(template) && target2.getAttribute("src") === owner.scene.variables.mediaUrl) {
|
|
462
546
|
markMediaFailed(sceneReadinessKey(owner.scene), "decode-error");
|
|
463
547
|
}
|
|
464
548
|
},
|
|
@@ -479,11 +563,30 @@ function VideoFrame({
|
|
|
479
563
|
MountedSceneReadiness,
|
|
480
564
|
{
|
|
481
565
|
scene: active.scene,
|
|
482
|
-
playing,
|
|
566
|
+
playing: playing && !handoffPending,
|
|
483
567
|
fallback: activeMediaFailed,
|
|
568
|
+
preparedProof: confirmedHandoff.current === sceneReadinessKey(active.scene) ? handoffProof.current : void 0,
|
|
484
569
|
onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene), "frame-readiness-timeout") : void 0
|
|
485
570
|
}
|
|
486
571
|
),
|
|
572
|
+
mountingNext && contiguousNext && preparingNext && /* @__PURE__ */ jsx2(
|
|
573
|
+
MountedSceneReadiness,
|
|
574
|
+
{
|
|
575
|
+
scene: contiguousNext.scene,
|
|
576
|
+
playing: playing || preparingNarration,
|
|
577
|
+
observeIncoming: true,
|
|
578
|
+
timeoutMs: handoffPending ? 8e3 : null,
|
|
579
|
+
onReady: (proof) => {
|
|
580
|
+
const key = sceneReadinessKey(contiguousNext.scene);
|
|
581
|
+
if (handoffPending) {
|
|
582
|
+
confirmedHandoff.current = key;
|
|
583
|
+
handoffProof.current = proof;
|
|
584
|
+
}
|
|
585
|
+
markPrepared(key);
|
|
586
|
+
},
|
|
587
|
+
onFailure: () => markMediaFailed(sceneReadinessKey(contiguousNext.scene), "frame-readiness-timeout")
|
|
588
|
+
}
|
|
589
|
+
),
|
|
487
590
|
/* @__PURE__ */ jsxs(
|
|
488
591
|
"div",
|
|
489
592
|
{
|
|
@@ -525,8 +628,8 @@ function VideoFrame({
|
|
|
525
628
|
motionProgress,
|
|
526
629
|
width: canvas.width,
|
|
527
630
|
height: canvas.height,
|
|
528
|
-
playing,
|
|
529
|
-
preparingNarration,
|
|
631
|
+
playing: handoffPending ? playing || preparingNarration : playing,
|
|
632
|
+
preparingNarration: handoffPending ? false : preparingNarration,
|
|
530
633
|
mediaAudioMuted,
|
|
531
634
|
mediaAudioVolume,
|
|
532
635
|
layer: previewingNext ? "outgoing" : "active",
|
|
@@ -547,9 +650,9 @@ function VideoFrame({
|
|
|
547
650
|
motionProgress: 0,
|
|
548
651
|
width: canvas.width,
|
|
549
652
|
height: canvas.height,
|
|
550
|
-
playing:
|
|
551
|
-
preparingNarration:
|
|
552
|
-
mediaAudioMuted,
|
|
653
|
+
playing: Boolean(preparingNext && (!preparedMedia.has(sceneReadinessKey(contiguousNext.scene)) || handoffPending) && (playing || preparingNarration)),
|
|
654
|
+
preparingNarration: preparingNext,
|
|
655
|
+
mediaAudioMuted: true,
|
|
553
656
|
mediaAudioVolume,
|
|
554
657
|
layer: "incoming",
|
|
555
658
|
opacity: blendProgress,
|
|
@@ -271,7 +271,7 @@ var SceneVideoBackdrop = ({
|
|
|
271
271
|
}
|
|
272
272
|
};
|
|
273
273
|
useEffect(() => {
|
|
274
|
-
if (!isPlaying || waitingKey !== videoPresentationKey) {
|
|
274
|
+
if (!isPlaying || rewindPreroll || waitingKey !== videoPresentationKey) {
|
|
275
275
|
if (waitingKey) setWaitingKey(void 0);
|
|
276
276
|
return;
|
|
277
277
|
}
|
|
@@ -298,6 +298,7 @@ var SceneVideoBackdrop = ({
|
|
|
298
298
|
else if (time > previousTime + 1e-3) forwardFrames++;
|
|
299
299
|
previousTime = time;
|
|
300
300
|
if (forwardFrames >= 2) {
|
|
301
|
+
playableVideoUrl.current = mediaUrl;
|
|
301
302
|
stopped = true;
|
|
302
303
|
clearTimeout(deadline);
|
|
303
304
|
setWaitingKey(void 0);
|
|
@@ -320,7 +321,7 @@ var SceneVideoBackdrop = ({
|
|
|
320
321
|
clearTimeout(poll);
|
|
321
322
|
if (frame !== void 0) video.cancelVideoFrameCallback?.(frame);
|
|
322
323
|
};
|
|
323
|
-
}, [waitingKey, videoPresentationKey, isPlaying]);
|
|
324
|
+
}, [waitingKey, videoPresentationKey, isPlaying, rewindPreroll]);
|
|
324
325
|
const onReadyRef = useRef(onReady);
|
|
325
326
|
onReadyRef.current = onReady;
|
|
326
327
|
useEffect(() => {
|
|
@@ -397,7 +398,7 @@ var SceneVideoBackdrop = ({
|
|
|
397
398
|
if (!video) return;
|
|
398
399
|
if (!isPlaying) {
|
|
399
400
|
video.pause();
|
|
400
|
-
if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;
|
|
401
|
+
if (rewindPreroll && !resolvedMuted && video.currentTime > 0) video.currentTime = 0;
|
|
401
402
|
return;
|
|
402
403
|
}
|
|
403
404
|
if (startedPlaybackId.current === playbackId) {
|
|
@@ -415,7 +416,7 @@ var SceneVideoBackdrop = ({
|
|
|
415
416
|
const enforceRequestedPause = (event) => {
|
|
416
417
|
if (presentationRef.current.playing) return;
|
|
417
418
|
event.currentTarget.pause();
|
|
418
|
-
if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;
|
|
419
|
+
if (rewindPreroll && !resolvedMuted && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;
|
|
419
420
|
};
|
|
420
421
|
const mediaStyle = {
|
|
421
422
|
position: "absolute",
|
|
@@ -444,7 +445,7 @@ var SceneVideoBackdrop = ({
|
|
|
444
445
|
src: mediaUrl,
|
|
445
446
|
poster: decodedVideoUrl !== mediaUrl ? mediaPoster || void 0 : void 0,
|
|
446
447
|
muted: resolvedMuted,
|
|
447
|
-
loop:
|
|
448
|
+
loop: resolvedMuted && isPlaying && Number.isFinite(sceneDuration) && Number(sceneDuration) > 0,
|
|
448
449
|
playsInline: true,
|
|
449
450
|
preload: "auto",
|
|
450
451
|
onLoadedMetadata: (event) => fitDuration(event.currentTarget),
|
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-MXLVTOGR.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-HYCDG65Z.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-VUQC6KPA.js").then((module) => ({ default: module.TimelineSceneTemplate })),
|
|
100
|
+
"mobileMessage": () => import("./mobile-message-FIHAO6XC.js").then((module) => ({ default: module.NotificationSceneTemplate })),
|
|
101
|
+
"comparison": () => import("./comparison-XXAP4S4J.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
|
|
102
|
+
"quote": () => import("./quote-GRPIYKQJ.js").then((module) => ({ default: module.QuoteSceneTemplate })),
|
|
103
|
+
"keyFigure": () => import("./key-figure-4TJK7HLT.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
// src/visual-system/catalog/builtin-player.generated.ts
|
|
@@ -3466,7 +3466,41 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3466
3466
|
}, [chat, listen]);
|
|
3467
3467
|
const shown = chat.shownTurn;
|
|
3468
3468
|
const showing = chat.playerProps != null;
|
|
3469
|
-
const
|
|
3469
|
+
const handoffKey = `${shown?.id ?? ""}:${chat.playerKey}`;
|
|
3470
|
+
const [presentedBody, setPresentedBody] = useState7();
|
|
3471
|
+
const handoff = useRef8({ key: handoffKey, live: showing, active: false, frame: 0 });
|
|
3472
|
+
const handoffStopped = chat.status === "error" || chat.status === "cancelled";
|
|
3473
|
+
const waitingForBody = showing && presentedBody !== handoffKey;
|
|
3474
|
+
const openingChapter = Boolean(shown?.prompt) && (!showing || waitingForBody) && chat.status !== "error" && chat.status !== "cancelled" && chat.status !== "ended";
|
|
3475
|
+
useLayoutEffect2(() => {
|
|
3476
|
+
const current = { key: handoffKey, live: showing && !handoffStopped, active: openingChapter && showing, frame: 0 };
|
|
3477
|
+
handoff.current = current;
|
|
3478
|
+
return () => {
|
|
3479
|
+
current.live = false;
|
|
3480
|
+
current.active = false;
|
|
3481
|
+
cancelAnimationFrame(current.frame);
|
|
3482
|
+
};
|
|
3483
|
+
}, [handoffKey, openingChapter, showing, handoffStopped]);
|
|
3484
|
+
const cueBody = (scene, index) => {
|
|
3485
|
+
const current = handoff.current;
|
|
3486
|
+
if (!current.live || current.key !== handoffKey) return;
|
|
3487
|
+
chat.playerProps?.onSceneChange?.(scene, index);
|
|
3488
|
+
if (!current.active) return;
|
|
3489
|
+
cancelAnimationFrame(current.frame);
|
|
3490
|
+
const reveal = () => {
|
|
3491
|
+
if (!current.active || handoff.current !== current) return;
|
|
3492
|
+
let ready = false;
|
|
3493
|
+
try {
|
|
3494
|
+
ready = chat.playerProps?.narrationReady?.() !== false;
|
|
3495
|
+
} catch {
|
|
3496
|
+
}
|
|
3497
|
+
if (ready) {
|
|
3498
|
+
current.active = false;
|
|
3499
|
+
setPresentedBody(handoffKey);
|
|
3500
|
+
} else current.frame = requestAnimationFrame(reveal);
|
|
3501
|
+
};
|
|
3502
|
+
reveal();
|
|
3503
|
+
};
|
|
3470
3504
|
const openingTitle = shown?.opening ?? shown?.prompt ?? "";
|
|
3471
3505
|
const status = chat.turns.length === 0 ? "idle" : chat.status === "composing" ? "drawing" : chat.status === "playing" ? "narrating" : chat.status === "error" || chat.status === "cancelled" ? "ended" : chat.status;
|
|
3472
3506
|
const shownOrientation = shown?.orientation ?? sessionOrientation;
|
|
@@ -3584,14 +3618,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3584
3618
|
] }),
|
|
3585
3619
|
/* @__PURE__ */ jsxs6("div", { className: "stage-area", children: [
|
|
3586
3620
|
/* @__PURE__ */ jsxs6("div", { className: "stage", style: { background: "#000" }, children: [
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
chat.turns.length === 0 && /* @__PURE__ */ jsx8(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle })
|
|
3590
|
-
] }),
|
|
3621
|
+
openingChapter && /* @__PURE__ */ jsx8(OpeningChapter, { title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
|
|
3622
|
+
!showing && chat.turns.length === 0 && /* @__PURE__ */ jsx8(Welcome, { data: chat.welcome, onAsk: ask, title: welcomeTitle }),
|
|
3591
3623
|
chat.playerProps && /* @__PURE__ */ jsx8("div", { className: "player-fit", style: { width: stageOrientation === "portrait" ? "min(100cqw, 56.25cqh)" : "min(100cqw, 177.7778cqh)" }, children: /* @__PURE__ */ jsx8(
|
|
3592
3624
|
VideoPlayer,
|
|
3593
3625
|
{
|
|
3594
3626
|
...chat.playerProps,
|
|
3627
|
+
onSceneChange: cueBody,
|
|
3595
3628
|
templates: options.templates,
|
|
3596
3629
|
orientation: stageOrientation,
|
|
3597
3630
|
responsiveBreakpoint: DESKTOP_WIDTH,
|
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 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"
|
|
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 // Hidden preparation is bounded by mounted readiness once its cut is due.\n // It must not spend the next scene's stall deadline while still incoming.\n if (!isPlaying || rewindPreroll || 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 playableVideoUrl.current = mediaUrl;\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, rewindPreroll]);\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 // Keep silent prepared data intact through narration startup. Seeking\n // back a few milliseconds can trigger another cold Range request.\n if (rewindPreroll && !resolvedMuted && 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 && !resolvedMuted && 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 // Let the decoder repeat without an ended → script seek/play round trip.\n // The finite scene clock still owns pause and disposal; never loop speech.\n loop={resolvedMuted && isPlaying && Number.isFinite(sceneDuration) && Number(sceneDuration) > 0}\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",
|
|
@@ -71,7 +71,9 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
|
|
|
71
71
|
if (selection && subject.length) {
|
|
72
72
|
const covers = (phrase: string) => terms(phrase).every(word => subject.includes(word));
|
|
73
73
|
if (!covers(selection.subject) || selection.exclude?.some(covers)) continue;
|
|
74
|
-
|
|
74
|
+
// Query context breaks equal hint matches without outweighing a hint.
|
|
75
|
+
const contextScore = matches / (tokens.length + 1);
|
|
76
|
+
matches = 2 + contextScore + Number(Boolean(selection.activity && covers(selection.activity)))
|
|
75
77
|
+ Number(Boolean(selection.equipment && covers(selection.equipment)));
|
|
76
78
|
}
|
|
77
79
|
// The documented Video resource can have only a numeric page URL and no
|