@vanillaskyai/video 0.10.10 → 0.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.10.12
8
+
9
+ - Use query context to choose between equally relevant Pexels subject matches in the starter, while preserving subject, activity and equipment priority.
10
+
11
+ ## 0.10.11
12
+
13
+ - Keep the opening chapter visible until the first body visual and narration are ready, without mounting a second player or replaying the opening.
14
+ - Keep outgoing silent footage moving, or a chapter readable, while the next clip becomes playable; retain bounded chapter recovery, pause/cancel behavior and two-video preparation.
15
+ - Recognize sustained native video motion, reuse fresh readiness across scene promotion, and preserve prepared footage during narration holds instead of rewinding it. Let silent clips loop natively within their finite scene.
16
+
7
17
  ## 0.10.10
8
18
 
9
19
  - Accept multiline streamed chat JSON objects without waiting for the entire answer, while retaining bounded parsing and strict scene validation.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VideoFrame
3
- } from "./chunk-FFKAHNYD.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-YERSNTFL.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,22 +86,38 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
61
86
  }
62
87
  if (isVideo) {
63
88
  const video = layer.querySelector("video");
64
- if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
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(() => {
104
+ callback = video.requestVideoFrameCallback((_now, metadata) => {
72
105
  callback = void 0;
73
- presented = video;
106
+ if (stopped) return;
107
+ const sameSource = video.isConnected && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src;
108
+ const mediaTime = metadata.mediaTime;
109
+ if (!sameSource || video.paused || video.seeking || !Number.isFinite(mediaTime) || previousMediaTime !== void 0 && mediaTime <= previousMediaTime + 1e-3) forwardFrames = 0;
110
+ else if (previousMediaTime !== void 0 && mediaTime > previousMediaTime + 1e-3) forwardFrames++;
111
+ previousMediaTime = sameSource && !video.paused && !video.seeking ? mediaTime : void 0;
112
+ if (sameSource) presented = video;
74
113
  check();
75
114
  });
76
115
  return;
77
116
  }
78
- finish(void 0, true);
79
- return;
117
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
118
+ finish(void 0, true);
119
+ return;
120
+ }
80
121
  }
81
122
  } else {
82
123
  const image = [...layer.querySelectorAll("img") ?? []].find((element) => element.getAttribute("src") === mediaUrl);
@@ -86,7 +127,7 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
86
127
  }
87
128
  }
88
129
  }
89
- if (performance.now() - start >= 8e3) {
130
+ if (timeoutMs !== null && performance.now() - start >= timeoutMs) {
90
131
  finish(new Error("Scene media did not become ready"));
91
132
  return;
92
133
  }
@@ -101,17 +142,26 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
101
142
  callback = void 0;
102
143
  check();
103
144
  };
145
+ const resetMotion = (event) => {
146
+ if (event.target === observed || event.target === presented) {
147
+ motionRevision++;
148
+ previousMediaTime = void 0;
149
+ forwardFrames = 0;
150
+ }
151
+ };
152
+ for (const type of ["pause", "waiting", "seeking"]) root?.addEventListener(type, resetMotion, true);
104
153
  root?.addEventListener("vanillasky:video-frame-presented", onPresented);
105
154
  check();
106
- const timeout = setTimeout(() => finish(new Error("Scene media did not become ready")), 8e3);
155
+ const timeout = timeoutMs === null ? void 0 : setTimeout(() => finish(new Error("Scene media did not become ready")), timeoutMs);
107
156
  return () => {
108
157
  root?.removeEventListener("vanillasky:video-frame-presented", onPresented);
158
+ for (const type of ["pause", "waiting", "seeking"]) root?.removeEventListener(type, resetMotion, true);
109
159
  stopped = true;
110
160
  clearTimeout(timeout);
111
161
  cancelAnimationFrame(frame);
112
162
  if (callback !== void 0) observed?.cancelVideoFrameCallback?.(callback);
113
163
  };
114
- }, [key, report, scene, playing, fallback]);
164
+ }, [key, report, scene, playing, fallback, observeIncoming, timeoutMs, preparedProof]);
115
165
  return /* @__PURE__ */ jsx("span", { ref: marker, hidden: true });
116
166
  }
117
167
 
@@ -356,6 +406,12 @@ function VideoFrame({
356
406
  style
357
407
  }) {
358
408
  const recoveryRoot = useRef2(null);
409
+ const displayedKey = useRef2(void 0);
410
+ const displayedWasPlaying = useRef2(false);
411
+ const handoffProof = useRef2(void 0);
412
+ const confirmedHandoff = useRef2(void 0);
413
+ const [preparedMedia, setPreparedMedia] = useState(() => /* @__PURE__ */ new Set());
414
+ const markPrepared = useCallback((key) => setPreparedMedia((previous2) => /* @__PURE__ */ new Set([...previous2, key])), []);
359
415
  const reportedFailures = useRef2(/* @__PURE__ */ new Set());
360
416
  const [failedMedia, setFailedMedia] = useState(() => /* @__PURE__ */ new Set());
361
417
  const markMediaFailed = useCallback((key, reason = "playback-error") => {
@@ -364,14 +420,18 @@ function VideoFrame({
364
420
  reportedFailures.current.add(key);
365
421
  recoveryRoot.current?.dispatchEvent(new CustomEvent("vanillasky:media-recovery", { bubbles: true, detail: { reason: ["decode-error", "frame-readiness-timeout", "stalled-media", "playback-error"].includes(reason) ? reason : "playback-error" } }));
366
422
  }
367
- setFailedMedia((previous) => previous.has(key) ? previous : /* @__PURE__ */ new Set([...previous, key]));
423
+ setFailedMedia((previous2) => previous2.has(key) ? previous2 : /* @__PURE__ */ new Set([...previous2, key]));
368
424
  }, [config.scenes]);
369
425
  useEffect2(() => {
370
426
  const currentKeys = new Set(config.scenes.map(sceneReadinessKey));
427
+ setPreparedMedia((previous2) => {
428
+ const retained = new Set([...previous2].filter((key) => currentKeys.has(key)));
429
+ return retained.size === previous2.size ? previous2 : retained;
430
+ });
371
431
  for (const key of reportedFailures.current) if (!currentKeys.has(key)) reportedFailures.current.delete(key);
372
- setFailedMedia((previous) => {
373
- const retained = new Set([...previous].filter((key) => currentKeys.has(key)));
374
- 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;
375
435
  });
376
436
  }, [config.scenes]);
377
437
  const decoderConstrainedDevice = useDecoderConstraint();
@@ -379,8 +439,29 @@ function VideoFrame({
379
439
  const lastRange = timeline.at(-1);
380
440
  const foundIndex = timeline.findIndex((range) => time >= range.start && time < range.end);
381
441
  const afterEnd = lastRange && time >= lastRange.end;
382
- const activeIndex = foundIndex >= 0 ? foundIndex : afterEnd ? timeline.length - 1 : -1;
383
- 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;
384
465
  if (!active) {
385
466
  return /* @__PURE__ */ jsx2(
386
467
  "div",
@@ -432,18 +513,21 @@ function VideoFrame({
432
513
  contiguousNext && decoderConstrainedDevice && !boundedPreparation && sceneHasVideoBackdrop(active) && sceneHasVideoBackdrop(contiguousNext)
433
514
  );
434
515
  const activeMediaFailed = failedMedia.has(sceneReadinessKey(active.scene));
435
- const mountingNext = Boolean(
516
+ const preparingNext = canRetain(active) && canPrepare(contiguousNext);
517
+ const nextPlayable = hasPlayableMedia(contiguousNext);
518
+ const mountingNext = handoffPending || Boolean(
436
519
  contiguousNext && !decoderConstrainedTransition && (boundedPreparation || time >= prerollStart) && time < blendEnd && (eligibleNextTransition || prerollsNext || boundedPreparation && sceneHasVideoBackdrop(contiguousNext))
437
520
  );
438
521
  const previewingNext = Boolean(
439
- eligibleNextTransition && time >= blendStart && time < blendEnd
522
+ eligibleNextTransition && time >= blendStart && time < blendEnd && (!preparingNext || nextPlayable)
440
523
  );
441
524
  const blendProgress = previewingNext && blendDuration > 0 ? Math.round(clamp01((time - blendStart) / blendDuration) * 1e6) / 1e6 : 0;
442
525
  const progress = rawProgress;
443
526
  const isFinalScene = activeIndex === timeline.length - 1;
444
527
  const presentsChapter = active.scene.templateId === "chapterTitle" || active.scene.templateId === "cinemaMedia" && (activeMediaFailed || !String(active.scene.variables.mediaUrl || "").trim());
445
528
  const finalHold = presentsChapter ? 0.76 : activeTiming?.holdProgress;
446
- const 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;
447
531
  const canvas = getDimensions(config.orientation);
448
532
  const scale = Math.min(width / canvas.width, height / canvas.height);
449
533
  const canvasLeft = (width - canvas.width * scale) / 2;
@@ -453,12 +537,12 @@ function VideoFrame({
453
537
  {
454
538
  ref: recoveryRoot,
455
539
  onErrorCapture: (event) => {
456
- const target = event.target;
457
- if (!(target instanceof HTMLVideoElement || target instanceof HTMLImageElement)) return;
458
- 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");
459
543
  const owner = [active, contiguousNext].find((range) => range?.scene.id === ownerId);
460
544
  const template = owner && kit.getTemplate(owner.scene.templateId);
461
- if (owner && template && supportsExternalVideoBackdrop(template) && target.getAttribute("src") === owner.scene.variables.mediaUrl) {
545
+ if (owner && template && supportsExternalVideoBackdrop(template) && target2.getAttribute("src") === owner.scene.variables.mediaUrl) {
462
546
  markMediaFailed(sceneReadinessKey(owner.scene), "decode-error");
463
547
  }
464
548
  },
@@ -479,11 +563,30 @@ function VideoFrame({
479
563
  MountedSceneReadiness,
480
564
  {
481
565
  scene: active.scene,
482
- playing,
566
+ playing: playing && !handoffPending,
483
567
  fallback: activeMediaFailed,
568
+ preparedProof: confirmedHandoff.current === sceneReadinessKey(active.scene) ? handoffProof.current : void 0,
484
569
  onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene), "frame-readiness-timeout") : void 0
485
570
  }
486
571
  ),
572
+ mountingNext && contiguousNext && preparingNext && /* @__PURE__ */ jsx2(
573
+ MountedSceneReadiness,
574
+ {
575
+ scene: contiguousNext.scene,
576
+ playing: playing || preparingNarration,
577
+ observeIncoming: true,
578
+ timeoutMs: handoffPending ? 8e3 : null,
579
+ onReady: (proof) => {
580
+ const key = sceneReadinessKey(contiguousNext.scene);
581
+ if (handoffPending) {
582
+ confirmedHandoff.current = key;
583
+ handoffProof.current = proof;
584
+ }
585
+ markPrepared(key);
586
+ },
587
+ onFailure: () => markMediaFailed(sceneReadinessKey(contiguousNext.scene), "frame-readiness-timeout")
588
+ }
589
+ ),
487
590
  /* @__PURE__ */ jsxs(
488
591
  "div",
489
592
  {
@@ -525,8 +628,8 @@ function VideoFrame({
525
628
  motionProgress,
526
629
  width: canvas.width,
527
630
  height: canvas.height,
528
- playing,
529
- preparingNarration,
631
+ playing: handoffPending ? playing || preparingNarration : playing,
632
+ preparingNarration: handoffPending ? false : preparingNarration,
530
633
  mediaAudioMuted,
531
634
  mediaAudioVolume,
532
635
  layer: previewingNext ? "outgoing" : "active",
@@ -547,9 +650,9 @@ function VideoFrame({
547
650
  motionProgress: 0,
548
651
  width: canvas.width,
549
652
  height: canvas.height,
550
- playing: false,
551
- preparingNarration: false,
552
- mediaAudioMuted,
653
+ playing: Boolean(preparingNext && (!preparedMedia.has(sceneReadinessKey(contiguousNext.scene)) || handoffPending) && (playing || preparingNarration)),
654
+ preparingNarration: preparingNext,
655
+ mediaAudioMuted: true,
553
656
  mediaAudioVolume,
554
657
  layer: "incoming",
555
658
  opacity: blendProgress,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-YERSNTFL.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";
@@ -271,7 +271,7 @@ var SceneVideoBackdrop = ({
271
271
  }
272
272
  };
273
273
  useEffect(() => {
274
- if (!isPlaying || waitingKey !== videoPresentationKey) {
274
+ if (!isPlaying || rewindPreroll || waitingKey !== videoPresentationKey) {
275
275
  if (waitingKey) setWaitingKey(void 0);
276
276
  return;
277
277
  }
@@ -298,6 +298,7 @@ var SceneVideoBackdrop = ({
298
298
  else if (time > previousTime + 1e-3) forwardFrames++;
299
299
  previousTime = time;
300
300
  if (forwardFrames >= 2) {
301
+ playableVideoUrl.current = mediaUrl;
301
302
  stopped = true;
302
303
  clearTimeout(deadline);
303
304
  setWaitingKey(void 0);
@@ -320,7 +321,7 @@ var SceneVideoBackdrop = ({
320
321
  clearTimeout(poll);
321
322
  if (frame !== void 0) video.cancelVideoFrameCallback?.(frame);
322
323
  };
323
- }, [waitingKey, videoPresentationKey, isPlaying]);
324
+ }, [waitingKey, videoPresentationKey, isPlaying, rewindPreroll]);
324
325
  const onReadyRef = useRef(onReady);
325
326
  onReadyRef.current = onReady;
326
327
  useEffect(() => {
@@ -397,7 +398,7 @@ var SceneVideoBackdrop = ({
397
398
  if (!video) return;
398
399
  if (!isPlaying) {
399
400
  video.pause();
400
- if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;
401
+ if (rewindPreroll && !resolvedMuted && video.currentTime > 0) video.currentTime = 0;
401
402
  return;
402
403
  }
403
404
  if (startedPlaybackId.current === playbackId) {
@@ -415,7 +416,7 @@ var SceneVideoBackdrop = ({
415
416
  const enforceRequestedPause = (event) => {
416
417
  if (presentationRef.current.playing) return;
417
418
  event.currentTarget.pause();
418
- if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;
419
+ if (rewindPreroll && !resolvedMuted && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;
419
420
  };
420
421
  const mediaStyle = {
421
422
  position: "absolute",
@@ -444,7 +445,7 @@ var SceneVideoBackdrop = ({
444
445
  src: mediaUrl,
445
446
  poster: decodedVideoUrl !== mediaUrl ? mediaPoster || void 0 : void 0,
446
447
  muted: resolvedMuted,
447
- loop: false,
448
+ loop: resolvedMuted && isPlaying && Number.isFinite(sceneDuration) && Number(sceneDuration) > 0,
448
449
  playsInline: true,
449
450
  preload: "auto",
450
451
  onLoadedMetadata: (event) => fitDuration(event.currentTarget),
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  MediaScene,
3
3
  MediaSceneTemplate
4
- } from "./chunk-3S5FXE6G.js";
5
- import "./chunk-YERSNTFL.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-XL3H42K5.js";
4
- import "./chunk-YERSNTFL.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-XL3H42K5.js";
4
- import "./chunk-YERSNTFL.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-XL3H42K5.js";
4
- import "./chunk-YERSNTFL.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-3S5FXE6G.js";
3
+ } from "./chunk-QSBDB4J2.js";
4
4
  import {
5
5
  spring
6
- } from "./chunk-YERSNTFL.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-XL3H42K5.js";
4
- import "./chunk-YERSNTFL.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-FFKAHNYD.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-IAK3LC2J.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-435UCJ45.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
- "mobileMessage": () => import("./mobile-message-GVFA56QP.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
- "comparison": () => import("./comparison-MCTN376M.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
- "quote": () => import("./quote-H2X4XGPB.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
- "keyFigure": () => import("./key-figure-BSOQ3TYN.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.10",
3
+ "version": "0.10.12",
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 playableVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingPlayback = playableVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {\n playableVideoUrl.current = mediaUrl;\n awaitingPlayback = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(awaitingPlayback ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // A decoded still with no future data is still cold, even after its first\n // frame callback. Keep the short bound only after playback was available.\n let deadline = setTimeout(fail, awaitingPlayback ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let stopped = false;\n let frame: number | undefined;\n const markPresented = () => {\n if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return false;\n if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReadyRef.current?.();\n setDecodedVideoUrl(mediaUrl);\n stopped = true;\n return true;\n };\n const observe = () => {\n if (stopped || frame !== undefined) return;\n if (video.requestVideoFrameCallback) {\n frame = video.requestVideoFrameCallback(() => {\n frame = undefined;\n if (!markPresented()) observe();\n });\n } else if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markPresented();\n };\n // A cached resource can finish loading while its Suspense tree is still\n // detached. Observe the mounted frame even if loadeddata was missed.\n video.addEventListener(\"loadeddata\", observe);\n observe();\n return () => {\n stopped = true;\n video.removeEventListener(\"loadeddata\", observe);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [mediaUrl, videoPresentationKey]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(() => unavailable());\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(() => unavailable());\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(() => unavailable());\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const enforceRequestedPause = (event: React.SyntheticEvent<HTMLVideoElement>) => {\n // WebKit may enter playback without a playing event while seeking or\n // waiting. Both native start events must honor the latest requested hold.\n if (presentationRef.current.playing) return;\n event.currentTarget.pause();\n if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;\n };\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n };\n return (\n <>\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onPlay={enforceRequestedPause}\n onPlaying={event => {\n if (event.currentTarget.currentSrc === event.currentTarget.src\n && event.currentTarget.getAttribute(\"src\") === mediaUrl\n && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n enforceRequestedPause(event);\n }}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop=\"scene\"\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
43
+ "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const playableVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n // Hidden preparation is bounded by mounted readiness once its cut is due.\n // It must not spend the next scene's stall deadline while still incoming.\n if (!isPlaying || rewindPreroll || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingPlayback = playableVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {\n playableVideoUrl.current = mediaUrl;\n awaitingPlayback = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n playableVideoUrl.current = mediaUrl;\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(awaitingPlayback ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // A decoded still with no future data is still cold, even after its first\n // frame callback. Keep the short bound only after playback was available.\n let deadline = setTimeout(fail, awaitingPlayback ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying, rewindPreroll]);\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let stopped = false;\n let frame: number | undefined;\n const markPresented = () => {\n if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return false;\n if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReadyRef.current?.();\n setDecodedVideoUrl(mediaUrl);\n stopped = true;\n return true;\n };\n const observe = () => {\n if (stopped || frame !== undefined) return;\n if (video.requestVideoFrameCallback) {\n frame = video.requestVideoFrameCallback(() => {\n frame = undefined;\n if (!markPresented()) observe();\n });\n } else if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markPresented();\n };\n // A cached resource can finish loading while its Suspense tree is still\n // detached. Observe the mounted frame even if loadeddata was missed.\n video.addEventListener(\"loadeddata\", observe);\n observe();\n return () => {\n stopped = true;\n video.removeEventListener(\"loadeddata\", observe);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [mediaUrl, videoPresentationKey]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(() => unavailable());\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n // Keep silent prepared data intact through narration startup. Seeking\n // back a few milliseconds can trigger another cold Range request.\n if (rewindPreroll && !resolvedMuted && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(() => unavailable());\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(() => unavailable());\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const enforceRequestedPause = (event: React.SyntheticEvent<HTMLVideoElement>) => {\n // WebKit may enter playback without a playing event while seeking or\n // waiting. Both native start events must honor the latest requested hold.\n if (presentationRef.current.playing) return;\n event.currentTarget.pause();\n if (rewindPreroll && !resolvedMuted && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;\n };\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n };\n return (\n <>\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n // Let the decoder repeat without an ended → script seek/play round trip.\n // The finite scene clock still owns pause and disposal; never loop speech.\n loop={resolvedMuted && isPlaying && Number.isFinite(sceneDuration) && Number(sceneDuration) > 0}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onPlay={enforceRequestedPause}\n onPlaying={event => {\n if (event.currentTarget.currentSrc === event.currentTarget.src\n && event.currentTarget.getAttribute(\"src\") === mediaUrl\n && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n enforceRequestedPause(event);\n }}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop=\"scene\"\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
44
44
  },
45
45
  {
46
46
  "path": "src/visual-system/scene-templates/color-utils.ts",
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@vanillaskyai/video": "0.10.10",
12
+ "@vanillaskyai/video": "0.10.12",
13
13
  "react": "^19.2.8",
14
14
  "react-dom": "^19.2.8",
15
15
  "@ai-sdk/anthropic": "^3.0.0",
@@ -71,7 +71,9 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
71
71
  if (selection && subject.length) {
72
72
  const covers = (phrase: string) => terms(phrase).every(word => subject.includes(word));
73
73
  if (!covers(selection.subject) || selection.exclude?.some(covers)) continue;
74
- matches = 2 + Number(Boolean(selection.activity && covers(selection.activity)))
74
+ // Query context breaks equal hint matches without outweighing a hint.
75
+ const contextScore = matches / (tokens.length + 1);
76
+ matches = 2 + contextScore + Number(Boolean(selection.activity && covers(selection.activity)))
75
77
  + Number(Boolean(selection.equipment && covers(selection.equipment)));
76
78
  }
77
79
  // The documented Video resource can have only a numeric page URL and no
@@ -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;