@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 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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VideoFrame
3
- } from "./chunk-KUCEOG2L.js";
3
+ } from "./chunk-MXLVTOGR.js";
4
4
  import "./chunk-5JBMYQP6.js";
5
5
  import "./chunk-224QNWRA.js";
6
6
  import "./chunk-SPVTJH3F.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-J7EGPURK.js";
4
+ } from "./chunk-YAHT3LST.js";
5
5
  import {
6
6
  editorialFont
7
7
  } from "./chunk-4YM2M62S.js";
@@ -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({ scene, playing, fallback = false, onFailure }) {
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 (!stopped) {
41
- stopped = true;
42
- if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
43
- if (error && onFailureRef.current) onFailureRef.current();
44
- else report(key, error, actualVideoFrame);
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 >= 2) {
65
- if (presented === video) {
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(() => finish(void 0, true));
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 >= 8e3) {
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")), 8e3);
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((previous) => previous.has(key) ? previous : /* @__PURE__ */ new Set([...previous, key]));
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((previous) => {
369
- const retained = new Set([...previous].filter((key) => currentKeys.has(key)));
370
- return retained.size === previous.size ? previous : retained;
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 activeIndex = foundIndex >= 0 ? foundIndex : afterEnd ? timeline.length - 1 : -1;
379
- const active = activeIndex >= 0 ? timeline[activeIndex] : void 0;
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 mountingNext = Boolean(
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 motionProgress = isFinalScene && finalHold !== void 0 ? Math.min(rawProgress, finalHold) : rawProgress;
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 target = event.target;
453
- if (!(target instanceof HTMLVideoElement || target instanceof HTMLImageElement)) return;
454
- const ownerId = target.closest("[data-layer-scene-id]")?.getAttribute("data-layer-scene-id");
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) && target.getAttribute("src") === owner.scene.variables.mediaUrl) {
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: false,
547
- preparingNarration: false,
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,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-J7EGPURK.js";
4
+ } from "./chunk-YAHT3LST.js";
5
5
 
6
6
  // src/visual-system/scene-templates/cinema-media.tsx
7
7
  import { jsx } from "react/jsx-runtime";
@@ -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 presentedVideoUrl = useRef(void 0);
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 awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;
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 && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {
291
- awaitingFirstFrame = false;
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(awaitingFirstFrame ? "frame-readiness-timeout" : "stalled-media");
313
+ unavailable(awaitingPlayback ? "frame-readiness-timeout" : "stalled-media");
312
314
  }
313
315
  };
314
- let deadline = setTimeout(fail, awaitingFirstFrame ? 8e3 : 1e3);
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
- presentedVideoUrl.current = mediaUrl;
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: false,
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: enforceRequestedPause,
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
  },
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  MediaScene,
3
3
  MediaSceneTemplate
4
- } from "./chunk-Q3LPOPHL.js";
5
- import "./chunk-J7EGPURK.js";
4
+ } from "./chunk-QSBDB4J2.js";
5
+ import "./chunk-YAHT3LST.js";
6
6
  import "./chunk-5JBMYQP6.js";
7
7
  import "./chunk-224QNWRA.js";
8
8
  export {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-R5ZX2LJV.js";
4
- import "./chunk-J7EGPURK.js";
3
+ } from "./chunk-HFVNAPHZ.js";
4
+ import "./chunk-YAHT3LST.js";
5
5
  import {
6
6
  editorialLabel,
7
7
  fade
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-R5ZX2LJV.js";
4
- import "./chunk-J7EGPURK.js";
3
+ } from "./chunk-HFVNAPHZ.js";
4
+ import "./chunk-YAHT3LST.js";
5
5
  import {
6
6
  fade
7
7
  } from "./chunk-4YM2M62S.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-R5ZX2LJV.js";
4
- import "./chunk-J7EGPURK.js";
3
+ } from "./chunk-HFVNAPHZ.js";
4
+ import "./chunk-YAHT3LST.js";
5
5
  import {
6
6
  editorialLabel,
7
7
  fade
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  MediaScene
3
- } from "./chunk-Q3LPOPHL.js";
3
+ } from "./chunk-QSBDB4J2.js";
4
4
  import {
5
5
  spring
6
- } from "./chunk-J7EGPURK.js";
6
+ } from "./chunk-YAHT3LST.js";
7
7
  import {
8
8
  editorialFont
9
9
  } from "./chunk-4YM2M62S.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-R5ZX2LJV.js";
4
- import "./chunk-J7EGPURK.js";
3
+ } from "./chunk-HFVNAPHZ.js";
4
+ import "./chunk-YAHT3LST.js";
5
5
  import {
6
6
  fade
7
7
  } from "./chunk-4YM2M62S.js";
package/dist/react.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  VideoFrame,
41
41
  getDimensions,
42
42
  sceneReadinessKey
43
- } from "./chunk-KUCEOG2L.js";
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-WUS7CKPC.js").then((module) => ({ default: module.MediaSceneTemplate })),
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-JHQZIPNE.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
- "mobileMessage": () => import("./mobile-message-CDKOERRZ.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
- "comparison": () => import("./comparison-T4OYCOH3.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
- "quote": () => import("./quote-JKABOMIG.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
- "keyFigure": () => import("./key-figure-YWN2OBQU.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
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 openingChapter = !showing && Boolean(shown?.prompt);
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
- !showing && /* @__PURE__ */ jsxs6(Fragment4, { children: [
3588
- openingChapter && /* @__PURE__ */ jsx8(OpeningChapter, { title: openingTitle.length > 120 ? `${openingTitle.slice(0, 117).trimEnd()}\u2026` : openingTitle }, shown.id),
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 newline = buffer.indexOf("\n");
1027
- while (newline >= 0) {
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
- newline = buffer.indexOf("\n");
1066
+ raw = takeFrame();
1037
1067
  }
1038
1068
  }
1039
1069
  if (buffer.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -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",
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.9",
12
+ "@vanillaskyai/video": "0.10.11",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -1066,6 +1066,8 @@
1066
1066
  }
1067
1067
 
1068
1068
  .vanillasky-video-chat .opening-chapter {
1069
+ z-index: 2;
1070
+ pointer-events: none;
1069
1071
  position: absolute;
1070
1072
  inset: 0;
1071
1073
  background: #000;