@vanillaskyai/video 0.10.9 → 0.10.11
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 +12 -0
- package/dist/check-runtime.js +1 -1
- package/dist/{chunk-R5ZX2LJV.js → chunk-HFVNAPHZ.js} +1 -1
- package/dist/{chunk-KUCEOG2L.js → chunk-MXLVTOGR.js} +143 -36
- package/dist/{chunk-Q3LPOPHL.js → chunk-QSBDB4J2.js} +1 -1
- package/dist/{chunk-J7EGPURK.js → chunk-YAHT3LST.js} +18 -13
- package/dist/{cinema-media-WUS7CKPC.js → cinema-media-HYCDG65Z.js} +2 -2
- package/dist/{comparison-T4OYCOH3.js → comparison-XXAP4S4J.js} +2 -2
- package/dist/{editorial-timeline-JHQZIPNE.js → editorial-timeline-VUQC6KPA.js} +2 -2
- package/dist/{key-figure-YWN2OBQU.js → key-figure-4TJK7HLT.js} +2 -2
- package/dist/{mobile-message-CDKOERRZ.js → mobile-message-FIHAO6XC.js} +2 -2
- package/dist/{quote-JKABOMIG.js → quote-GRPIYKQJ.js} +2 -2
- package/dist/react.js +45 -12
- package/dist/server.js +35 -5
- package/package.json +1 -1
- package/registry/items/backgrounds.json +1 -1
- package/starters/video-chat/package.json +1 -1
- package/styles/video-chat.css +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.10.11
|
|
8
|
+
|
|
9
|
+
- Keep the opening chapter visible until the first body visual and narration are ready, without mounting a second player or replaying the opening.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
## 0.10.10
|
|
14
|
+
|
|
15
|
+
- Accept multiline streamed chat JSON objects without waiting for the entire answer, while retaining bounded parsing and strict scene validation.
|
|
16
|
+
|
|
17
|
+
- Wait for a presented frame with playable future data before starting narration, retaining bounded cold startup and quick recovery for footage that stalls after playback was available.
|
|
18
|
+
|
|
7
19
|
## 0.10.9
|
|
8
20
|
|
|
9
21
|
- Use essential subject hints and explicit exclusions when selecting starter Pexels footage, and isolate cached selections by those hints. Metadata-free results remain unverified provider-ranked fallbacks.
|
package/dist/check-runtime.js
CHANGED
|
@@ -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,18 +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 >=
|
|
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) => {
|
|
105
|
+
callback = void 0;
|
|
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;
|
|
113
|
+
check();
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
118
|
+
finish(void 0, true);
|
|
72
119
|
return;
|
|
73
120
|
}
|
|
74
|
-
finish(void 0, true);
|
|
75
|
-
return;
|
|
76
121
|
}
|
|
77
122
|
} else {
|
|
78
123
|
const image = [...layer.querySelectorAll("img") ?? []].find((element) => element.getAttribute("src") === mediaUrl);
|
|
@@ -82,7 +127,7 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
82
127
|
}
|
|
83
128
|
}
|
|
84
129
|
}
|
|
85
|
-
if (performance.now() - start >=
|
|
130
|
+
if (timeoutMs !== null && performance.now() - start >= timeoutMs) {
|
|
86
131
|
finish(new Error("Scene media did not become ready"));
|
|
87
132
|
return;
|
|
88
133
|
}
|
|
@@ -97,17 +142,26 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
97
142
|
callback = void 0;
|
|
98
143
|
check();
|
|
99
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);
|
|
100
153
|
root?.addEventListener("vanillasky:video-frame-presented", onPresented);
|
|
101
154
|
check();
|
|
102
|
-
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);
|
|
103
156
|
return () => {
|
|
104
157
|
root?.removeEventListener("vanillasky:video-frame-presented", onPresented);
|
|
158
|
+
for (const type of ["pause", "waiting", "seeking"]) root?.removeEventListener(type, resetMotion, true);
|
|
105
159
|
stopped = true;
|
|
106
160
|
clearTimeout(timeout);
|
|
107
161
|
cancelAnimationFrame(frame);
|
|
108
162
|
if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
|
|
109
163
|
};
|
|
110
|
-
}, [key, report, scene, playing, fallback]);
|
|
164
|
+
}, [key, report, scene, playing, fallback, observeIncoming, timeoutMs, preparedProof]);
|
|
111
165
|
return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
|
|
112
166
|
}
|
|
113
167
|
|
|
@@ -352,6 +406,12 @@ function VideoFrame({
|
|
|
352
406
|
style
|
|
353
407
|
}) {
|
|
354
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])), []);
|
|
355
415
|
const reportedFailures = useRef2(/* @__PURE__ */ new Set());
|
|
356
416
|
const [failedMedia, setFailedMedia] = useState(() => /* @__PURE__ */ new Set());
|
|
357
417
|
const markMediaFailed = useCallback((key, reason = "playback-error") => {
|
|
@@ -360,14 +420,18 @@ function VideoFrame({
|
|
|
360
420
|
reportedFailures.current.add(key);
|
|
361
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" } }));
|
|
362
422
|
}
|
|
363
|
-
setFailedMedia((
|
|
423
|
+
setFailedMedia((previous2) => previous2.has(key) ? previous2 : /* @__PURE__ */ new Set([...previous2, key]));
|
|
364
424
|
}, [config.scenes]);
|
|
365
425
|
useEffect2(() => {
|
|
366
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
|
+
});
|
|
367
431
|
for (const key of reportedFailures.current) if (!currentKeys.has(key)) reportedFailures.current.delete(key);
|
|
368
|
-
setFailedMedia((
|
|
369
|
-
const retained = new Set([...
|
|
370
|
-
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;
|
|
371
435
|
});
|
|
372
436
|
}, [config.scenes]);
|
|
373
437
|
const decoderConstrainedDevice = useDecoderConstraint();
|
|
@@ -375,8 +439,29 @@ function VideoFrame({
|
|
|
375
439
|
const lastRange = timeline.at(-1);
|
|
376
440
|
const foundIndex = timeline.findIndex((range) => time >= range.start && time < range.end);
|
|
377
441
|
const afterEnd = lastRange && time >= lastRange.end;
|
|
378
|
-
const
|
|
379
|
-
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;
|
|
380
465
|
if (!active) {
|
|
381
466
|
return /* @__PURE__ */ jsx2(
|
|
382
467
|
"div",
|
|
@@ -428,18 +513,21 @@ function VideoFrame({
|
|
|
428
513
|
contiguousNext && decoderConstrainedDevice && !boundedPreparation && sceneHasVideoBackdrop(active) && sceneHasVideoBackdrop(contiguousNext)
|
|
429
514
|
);
|
|
430
515
|
const activeMediaFailed = failedMedia.has(sceneReadinessKey(active.scene));
|
|
431
|
-
const
|
|
516
|
+
const preparingNext = canRetain(active) && canPrepare(contiguousNext);
|
|
517
|
+
const nextPlayable = hasPlayableMedia(contiguousNext);
|
|
518
|
+
const mountingNext = handoffPending || Boolean(
|
|
432
519
|
contiguousNext && !decoderConstrainedTransition && (boundedPreparation || time >= prerollStart) && time < blendEnd && (eligibleNextTransition || prerollsNext || boundedPreparation && sceneHasVideoBackdrop(contiguousNext))
|
|
433
520
|
);
|
|
434
521
|
const previewingNext = Boolean(
|
|
435
|
-
eligibleNextTransition && time >= blendStart && time < blendEnd
|
|
522
|
+
eligibleNextTransition && time >= blendStart && time < blendEnd && (!preparingNext || nextPlayable)
|
|
436
523
|
);
|
|
437
524
|
const blendProgress = previewingNext && blendDuration > 0 ? Math.round(clamp01((time - blendStart) / blendDuration) * 1e6) / 1e6 : 0;
|
|
438
525
|
const progress = rawProgress;
|
|
439
526
|
const isFinalScene = activeIndex === timeline.length - 1;
|
|
440
527
|
const presentsChapter = active.scene.templateId === "chapterTitle" || active.scene.templateId === "cinemaMedia" && (activeMediaFailed || !String(active.scene.variables.mediaUrl || "").trim());
|
|
441
528
|
const finalHold = presentsChapter ? 0.76 : activeTiming?.holdProgress;
|
|
442
|
-
const
|
|
529
|
+
const holdChapter = presentsChapter && preparingNext && !nextPlayable;
|
|
530
|
+
const motionProgress = (isFinalScene || holdChapter) && finalHold !== void 0 ? Math.min(rawProgress, finalHold) : rawProgress;
|
|
443
531
|
const canvas = getDimensions(config.orientation);
|
|
444
532
|
const scale = Math.min(width / canvas.width, height / canvas.height);
|
|
445
533
|
const canvasLeft = (width - canvas.width * scale) / 2;
|
|
@@ -449,12 +537,12 @@ function VideoFrame({
|
|
|
449
537
|
{
|
|
450
538
|
ref: recoveryRoot,
|
|
451
539
|
onErrorCapture: (event) => {
|
|
452
|
-
const
|
|
453
|
-
if (!(
|
|
454
|
-
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");
|
|
455
543
|
const owner = [active, contiguousNext].find((range) => range?.scene.id === ownerId);
|
|
456
544
|
const template = owner && kit.getTemplate(owner.scene.templateId);
|
|
457
|
-
if (owner && template && supportsExternalVideoBackdrop(template) &&
|
|
545
|
+
if (owner && template && supportsExternalVideoBackdrop(template) && target2.getAttribute("src") === owner.scene.variables.mediaUrl) {
|
|
458
546
|
markMediaFailed(sceneReadinessKey(owner.scene), "decode-error");
|
|
459
547
|
}
|
|
460
548
|
},
|
|
@@ -475,11 +563,30 @@ function VideoFrame({
|
|
|
475
563
|
MountedSceneReadiness,
|
|
476
564
|
{
|
|
477
565
|
scene: active.scene,
|
|
478
|
-
playing,
|
|
566
|
+
playing: playing && !handoffPending,
|
|
479
567
|
fallback: activeMediaFailed,
|
|
568
|
+
preparedProof: confirmedHandoff.current === sceneReadinessKey(active.scene) ? handoffProof.current : void 0,
|
|
480
569
|
onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene), "frame-readiness-timeout") : void 0
|
|
481
570
|
}
|
|
482
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
|
+
),
|
|
483
590
|
/* @__PURE__ */ jsxs(
|
|
484
591
|
"div",
|
|
485
592
|
{
|
|
@@ -521,8 +628,8 @@ function VideoFrame({
|
|
|
521
628
|
motionProgress,
|
|
522
629
|
width: canvas.width,
|
|
523
630
|
height: canvas.height,
|
|
524
|
-
playing,
|
|
525
|
-
preparingNarration,
|
|
631
|
+
playing: handoffPending ? playing || preparingNarration : playing,
|
|
632
|
+
preparingNarration: handoffPending ? false : preparingNarration,
|
|
526
633
|
mediaAudioMuted,
|
|
527
634
|
mediaAudioVolume,
|
|
528
635
|
layer: previewingNext ? "outgoing" : "active",
|
|
@@ -543,9 +650,9 @@ function VideoFrame({
|
|
|
543
650
|
motionProgress: 0,
|
|
544
651
|
width: canvas.width,
|
|
545
652
|
height: canvas.height,
|
|
546
|
-
playing:
|
|
547
|
-
preparingNarration:
|
|
548
|
-
mediaAudioMuted,
|
|
653
|
+
playing: Boolean(preparingNext && (!preparedMedia.has(sceneReadinessKey(contiguousNext.scene)) || handoffPending) && (playing || preparingNarration)),
|
|
654
|
+
preparingNarration: preparingNext,
|
|
655
|
+
mediaAudioMuted: true,
|
|
549
656
|
mediaAudioVolume,
|
|
550
657
|
layer: "incoming",
|
|
551
658
|
opacity: blendProgress,
|
|
@@ -257,7 +257,7 @@ var SceneVideoBackdrop = ({
|
|
|
257
257
|
const [waitingKey, setWaitingKey] = useState();
|
|
258
258
|
const [exhaustedKey, setExhaustedKey] = useState();
|
|
259
259
|
const videoRef = useRef(null);
|
|
260
|
-
const
|
|
260
|
+
const playableVideoUrl = useRef(void 0);
|
|
261
261
|
const startedVideoUrl = useRef(void 0);
|
|
262
262
|
const startedPlaybackId = useRef(void 0);
|
|
263
263
|
const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
|
|
@@ -271,14 +271,14 @@ 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
|
}
|
|
278
278
|
const video = videoRef.current;
|
|
279
279
|
if (!video) return;
|
|
280
280
|
const expectedSource = video.getAttribute("src") === mediaUrl ? video.src : void 0;
|
|
281
|
-
let
|
|
281
|
+
let awaitingPlayback = playableVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;
|
|
282
282
|
let previousTime = video.currentTime;
|
|
283
283
|
let forwardFrames = 0;
|
|
284
284
|
let stopped = false;
|
|
@@ -287,8 +287,9 @@ var SceneVideoBackdrop = ({
|
|
|
287
287
|
const observe = (_now, metadata) => {
|
|
288
288
|
if (stopped) return;
|
|
289
289
|
const currentSource = video.currentSrc === expectedSource;
|
|
290
|
-
if (currentSource &&
|
|
291
|
-
|
|
290
|
+
if (currentSource && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
291
|
+
playableVideoUrl.current = mediaUrl;
|
|
292
|
+
awaitingPlayback = false;
|
|
292
293
|
clearTimeout(deadline);
|
|
293
294
|
deadline = setTimeout(fail, 1e3);
|
|
294
295
|
}
|
|
@@ -297,6 +298,7 @@ var SceneVideoBackdrop = ({
|
|
|
297
298
|
else if (time > previousTime + 1e-3) forwardFrames++;
|
|
298
299
|
previousTime = time;
|
|
299
300
|
if (forwardFrames >= 2) {
|
|
301
|
+
playableVideoUrl.current = mediaUrl;
|
|
300
302
|
stopped = true;
|
|
301
303
|
clearTimeout(deadline);
|
|
302
304
|
setWaitingKey(void 0);
|
|
@@ -308,10 +310,10 @@ var SceneVideoBackdrop = ({
|
|
|
308
310
|
const fail = () => {
|
|
309
311
|
if (!stopped) {
|
|
310
312
|
stopped = true;
|
|
311
|
-
unavailable(
|
|
313
|
+
unavailable(awaitingPlayback ? "frame-readiness-timeout" : "stalled-media");
|
|
312
314
|
}
|
|
313
315
|
};
|
|
314
|
-
let deadline = setTimeout(fail,
|
|
316
|
+
let deadline = setTimeout(fail, awaitingPlayback ? 8e3 : 1e3);
|
|
315
317
|
observe();
|
|
316
318
|
return () => {
|
|
317
319
|
stopped = true;
|
|
@@ -319,7 +321,7 @@ var SceneVideoBackdrop = ({
|
|
|
319
321
|
clearTimeout(poll);
|
|
320
322
|
if (frame !== void 0) video.cancelVideoFrameCallback?.(frame);
|
|
321
323
|
};
|
|
322
|
-
}, [waitingKey, videoPresentationKey, isPlaying]);
|
|
324
|
+
}, [waitingKey, videoPresentationKey, isPlaying, rewindPreroll]);
|
|
323
325
|
const onReadyRef = useRef(onReady);
|
|
324
326
|
onReadyRef.current = onReady;
|
|
325
327
|
useEffect(() => {
|
|
@@ -329,7 +331,7 @@ var SceneVideoBackdrop = ({
|
|
|
329
331
|
let frame;
|
|
330
332
|
const markPresented = () => {
|
|
331
333
|
if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey || video.getAttribute("src") !== mediaUrl || video.currentSrc !== video.src) return false;
|
|
332
|
-
|
|
334
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;
|
|
333
335
|
video.dispatchEvent(new Event("vanillasky:video-frame-presented", { bubbles: true }));
|
|
334
336
|
onReadyRef.current?.();
|
|
335
337
|
setDecodedVideoUrl(mediaUrl);
|
|
@@ -396,7 +398,7 @@ var SceneVideoBackdrop = ({
|
|
|
396
398
|
if (!video) return;
|
|
397
399
|
if (!isPlaying) {
|
|
398
400
|
video.pause();
|
|
399
|
-
if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;
|
|
401
|
+
if (rewindPreroll && !resolvedMuted && video.currentTime > 0) video.currentTime = 0;
|
|
400
402
|
return;
|
|
401
403
|
}
|
|
402
404
|
if (startedPlaybackId.current === playbackId) {
|
|
@@ -414,7 +416,7 @@ var SceneVideoBackdrop = ({
|
|
|
414
416
|
const enforceRequestedPause = (event) => {
|
|
415
417
|
if (presentationRef.current.playing) return;
|
|
416
418
|
event.currentTarget.pause();
|
|
417
|
-
if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;
|
|
419
|
+
if (rewindPreroll && !resolvedMuted && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;
|
|
418
420
|
};
|
|
419
421
|
const mediaStyle = {
|
|
420
422
|
position: "absolute",
|
|
@@ -443,13 +445,16 @@ var SceneVideoBackdrop = ({
|
|
|
443
445
|
src: mediaUrl,
|
|
444
446
|
poster: decodedVideoUrl !== mediaUrl ? mediaPoster || void 0 : void 0,
|
|
445
447
|
muted: resolvedMuted,
|
|
446
|
-
loop:
|
|
448
|
+
loop: resolvedMuted && isPlaying && Number.isFinite(sceneDuration) && Number(sceneDuration) > 0,
|
|
447
449
|
playsInline: true,
|
|
448
450
|
preload: "auto",
|
|
449
451
|
onLoadedMetadata: (event) => fitDuration(event.currentTarget),
|
|
450
452
|
onEnded: (event) => continueMotion(event.currentTarget),
|
|
451
453
|
onPlay: enforceRequestedPause,
|
|
452
|
-
onPlaying:
|
|
454
|
+
onPlaying: (event) => {
|
|
455
|
+
if (event.currentTarget.currentSrc === event.currentTarget.src && event.currentTarget.getAttribute("src") === mediaUrl && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;
|
|
456
|
+
enforceRequestedPause(event);
|
|
457
|
+
},
|
|
453
458
|
onWaiting: () => {
|
|
454
459
|
if (isPlaying) setWaitingKey(videoPresentationKey);
|
|
455
460
|
},
|
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/dist/server.js
CHANGED
|
@@ -1017,23 +1017,53 @@ function createChatShotPlanner(options) {
|
|
|
1017
1017
|
bodyDuration += shot.durationSec;
|
|
1018
1018
|
return scenePart(shot);
|
|
1019
1019
|
};
|
|
1020
|
+
let cursor = 0, depth = 0, quoted = false, escaped = false;
|
|
1021
|
+
const takeFrame = () => {
|
|
1022
|
+
if (cursor === 0) {
|
|
1023
|
+
buffer = buffer.trimStart();
|
|
1024
|
+
if (!buffer) return;
|
|
1025
|
+
if (buffer[0] !== "{" && buffer[0] !== "[") {
|
|
1026
|
+
const newline = buffer.indexOf("\n");
|
|
1027
|
+
if (newline < 0) return;
|
|
1028
|
+
const raw = buffer.slice(0, newline);
|
|
1029
|
+
buffer = buffer.slice(newline + 1);
|
|
1030
|
+
return raw;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
for (; cursor < buffer.length; cursor++) {
|
|
1034
|
+
const character = buffer[cursor];
|
|
1035
|
+
if (quoted) {
|
|
1036
|
+
if (escaped) escaped = false;
|
|
1037
|
+
else if (character === "\\") escaped = true;
|
|
1038
|
+
else if (character === '"') quoted = false;
|
|
1039
|
+
} else if (character === '"') quoted = true;
|
|
1040
|
+
else if (character === "{" || character === "[") depth++;
|
|
1041
|
+
else if (character === "}" || character === "]") {
|
|
1042
|
+
depth--;
|
|
1043
|
+
if (depth === 0) {
|
|
1044
|
+
const raw = buffer.slice(0, cursor + 1);
|
|
1045
|
+
buffer = buffer.slice(cursor + 1);
|
|
1046
|
+
cursor = 0;
|
|
1047
|
+
return raw;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1020
1052
|
try {
|
|
1021
1053
|
for await (const delta of upstream) {
|
|
1022
1054
|
context.signal.throwIfAborted();
|
|
1023
1055
|
if (typeof delta !== "string") throw new Error("The LLM adapter returned a non-text delta");
|
|
1024
1056
|
buffer += delta;
|
|
1025
1057
|
if (buffer.length > 32768) throw new Error("Chat plan line exceeds the bounded stream limit");
|
|
1026
|
-
let
|
|
1027
|
-
while (
|
|
1028
|
-
const raw = buffer.slice(0, newline);
|
|
1029
|
-
buffer = buffer.slice(newline + 1);
|
|
1058
|
+
let raw = takeFrame();
|
|
1059
|
+
while (raw !== void 0) {
|
|
1030
1060
|
try {
|
|
1031
1061
|
const part = line(raw);
|
|
1032
1062
|
if (part) yield JSON.stringify(part) + "\n";
|
|
1033
1063
|
} catch (cause) {
|
|
1034
1064
|
reject(cause);
|
|
1035
1065
|
}
|
|
1036
|
-
|
|
1066
|
+
raw = takeFrame();
|
|
1037
1067
|
}
|
|
1038
1068
|
}
|
|
1039
1069
|
if (buffer.trim()) {
|
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"path": "src/visual-system/scene-templates/scene-video-backdrop.tsx",
|
|
41
41
|
"type": "registry:lib",
|
|
42
42
|
"target": "vanillasky/scene-templates/scene-video-backdrop.tsx",
|
|
43
|
-
"content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const presentedVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {\n awaitingFirstFrame = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(awaitingFirstFrame ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // Only a source that has presented a frame can be judged as stalled motion.\n let deadline = setTimeout(fail, awaitingFirstFrame ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let stopped = false;\n let frame: number | undefined;\n const markPresented = () => {\n if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return false;\n presentedVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReadyRef.current?.();\n setDecodedVideoUrl(mediaUrl);\n stopped = true;\n return true;\n };\n const observe = () => {\n if (stopped || frame !== undefined) return;\n if (video.requestVideoFrameCallback) {\n frame = video.requestVideoFrameCallback(() => {\n frame = undefined;\n if (!markPresented()) observe();\n });\n } else if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markPresented();\n };\n // A cached resource can finish loading while its Suspense tree is still\n // detached. Observe the mounted frame even if loadeddata was missed.\n video.addEventListener(\"loadeddata\", observe);\n observe();\n return () => {\n stopped = true;\n video.removeEventListener(\"loadeddata\", observe);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [mediaUrl, videoPresentationKey]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(() => unavailable());\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(() => unavailable());\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(() => unavailable());\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const enforceRequestedPause = (event: React.SyntheticEvent<HTMLVideoElement>) => {\n // WebKit may enter playback without a playing event while seeking or\n // waiting. Both native start events must honor the latest requested hold.\n if (presentationRef.current.playing) return;\n event.currentTarget.pause();\n if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;\n };\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n };\n return (\n <>\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onPlay={enforceRequestedPause}\n onPlaying={enforceRequestedPause}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop=\"scene\"\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
|
|
43
|
+
"content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const playableVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n // 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",
|