@vanillaskyai/video 0.10.7 → 0.10.9

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.
@@ -1,11 +1,11 @@
1
+ import {
2
+ ExternalVideoBackdropProvider,
3
+ resolveDensity
4
+ } from "./chunk-5JBMYQP6.js";
1
5
  import {
2
6
  limitsConcurrentVideoDecoders,
3
7
  resolveMediaType
4
8
  } from "./chunk-224QNWRA.js";
5
- import {
6
- ExternalVideoBackdropProvider,
7
- resolveDensity
8
- } from "./chunk-OOBT4X46.js";
9
9
  import {
10
10
  resolveVideoTimeline
11
11
  } from "./chunk-SPVTJH3F.js";
@@ -60,8 +60,7 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
60
60
  return;
61
61
  }
62
62
  if (isVideo) {
63
- const persistent = root.querySelector("[data-persistent-video-scene-id]");
64
- const video = (persistent?.getAttribute("data-persistent-video-scene-id") === scene.id ? persistent : layer)?.querySelector("video");
63
+ const video = layer.querySelector("video");
65
64
  if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= 2) {
66
65
  if (presented === video) {
67
66
  finish(void 0, true);
@@ -116,9 +115,9 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
116
115
  import {
117
116
  createElement,
118
117
  Component,
119
- lazy,
120
118
  Suspense,
121
119
  useState,
120
+ useRef as useRef2,
122
121
  useCallback,
123
122
  useEffect as useEffect2,
124
123
  useSyncExternalStore
@@ -193,7 +192,6 @@ var SceneBoundary = class extends Component {
193
192
  }
194
193
  };
195
194
  var SCENE_TRANSITION_SECONDS = 0.3;
196
- var SceneVideoBackdrop = lazy(() => import("./scene-video-backdrop-NK2XK3XE.js").then((module) => ({ default: module.SceneVideoBackdrop })));
197
195
  var MEDIA_PREROLL_SECONDS = 1.2;
198
196
  var CONTIGUITY_ULP_FACTOR = 4;
199
197
  var subscribeToDecoderPolicy = () => () => {
@@ -353,19 +351,25 @@ function VideoFrame({
353
351
  className,
354
352
  style
355
353
  }) {
354
+ const recoveryRoot = useRef2(null);
355
+ const reportedFailures = useRef2(/* @__PURE__ */ new Set());
356
356
  const [failedMedia, setFailedMedia] = useState(() => /* @__PURE__ */ new Set());
357
- const markMediaFailed = useCallback((key) => {
357
+ const markMediaFailed = useCallback((key, reason = "playback-error") => {
358
358
  if (!key || !config.scenes.some((scene) => sceneReadinessKey(scene) === key)) return;
359
+ if (!reportedFailures.current.has(key)) {
360
+ reportedFailures.current.add(key);
361
+ 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
+ }
359
363
  setFailedMedia((previous) => previous.has(key) ? previous : /* @__PURE__ */ new Set([...previous, key]));
360
364
  }, [config.scenes]);
361
365
  useEffect2(() => {
362
366
  const currentKeys = new Set(config.scenes.map(sceneReadinessKey));
367
+ for (const key of reportedFailures.current) if (!currentKeys.has(key)) reportedFailures.current.delete(key);
363
368
  setFailedMedia((previous) => {
364
369
  const retained = new Set([...previous].filter((key) => currentKeys.has(key)));
365
370
  return retained.size === previous.size ? previous : retained;
366
371
  });
367
372
  }, [config.scenes]);
368
- const [readyPersistentVideo, setReadyPersistentVideo] = useState();
369
373
  const decoderConstrainedDevice = useDecoderConstraint();
370
374
  const timeline = resolveVideoTimeline(config);
371
375
  const lastRange = timeline.at(-1);
@@ -419,34 +423,13 @@ function VideoFrame({
419
423
  );
420
424
  const prerollDuration = prerollsNext ? Math.min(Math.max(MEDIA_PREROLL_SECONDS, blendDuration), Math.max(0, duration)) : blendDuration;
421
425
  const prerollStart = active.end - prerollDuration;
426
+ const boundedPreparation = decoderConstrainedDevice && registrySupportsExternalVideoBackdrop(kit);
422
427
  const decoderConstrainedTransition = Boolean(
423
- contiguousNext && decoderConstrainedDevice && sceneHasVideoBackdrop(active) && sceneHasVideoBackdrop(contiguousNext)
428
+ contiguousNext && decoderConstrainedDevice && !boundedPreparation && sceneHasVideoBackdrop(active) && sceneHasVideoBackdrop(contiguousNext)
424
429
  );
425
- const persistentVideoEnabled = decoderConstrainedDevice && registrySupportsExternalVideoBackdrop(kit);
426
- const activeUsesPersistentVideo = persistentVideoEnabled && sceneHasVideoBackdrop(active);
427
- const incomingUsesPersistentVideo = Boolean(
428
- persistentVideoEnabled && contiguousNext && !sceneHasVideoBackdrop(active) && sceneHasVideoBackdrop(contiguousNext) && time >= prerollStart && time < blendEnd
429
- );
430
- const persistentVideoRange = activeUsesPersistentVideo ? active : incomingUsesPersistentVideo && contiguousNext ? contiguousNext : void 0;
431
- const persistentVideoKey = persistentVideoRange ? `${persistentVideoRange.scene.id}\0${String(persistentVideoRange.scene.variables.mediaUrl || "")}` : void 0;
432
- const firstVideoRange = timeline.find(sceneHasVideoBackdrop);
433
- const posterPreparationRange = activeUsesPersistentVideo ? contiguousNext && sceneHasVideoBackdrop(contiguousNext) ? contiguousNext : activeIndex === timeline.length - 1 && firstVideoRange?.scene.id !== active.scene.id ? firstVideoRange : void 0 : void 0;
434
- const preparedPoster = posterPreparationRange && String(
435
- posterPreparationRange.scene.variables.mediaPoster || ""
436
- ) ? {
437
- presentationKey: `${posterPreparationRange.scene.id}\0${String(
438
- posterPreparationRange.scene.variables.mediaUrl || ""
439
- )}`,
440
- mediaPoster: String(posterPreparationRange.scene.variables.mediaPoster),
441
- mediaPosition: String(posterPreparationRange.scene.variables.mediaPosition || "center"),
442
- backgroundEffect: posterPreparationRange.scene.backgroundEffect ?? config.style.defaultBackgroundEffect
443
- } : void 0;
444
430
  const activeMediaFailed = failedMedia.has(sceneReadinessKey(active.scene));
445
- const persistentVideoFailed = persistentVideoKey !== void 0 && failedMedia.has(persistentVideoKey);
446
- const persistentVideoReady = persistentVideoRange !== void 0 && (String(persistentVideoRange.scene.variables.mediaPoster || "") !== "" || readyPersistentVideo === persistentVideoKey);
447
- const persistentVideoMode = persistentVideoFailed ? "fallback" : persistentVideoReady ? "ready" : "pending";
448
431
  const mountingNext = Boolean(
449
- contiguousNext && !decoderConstrainedTransition && time >= prerollStart && time < blendEnd && (eligibleNextTransition || prerollsNext)
432
+ contiguousNext && !decoderConstrainedTransition && (boundedPreparation || time >= prerollStart) && time < blendEnd && (eligibleNextTransition || prerollsNext || boundedPreparation && sceneHasVideoBackdrop(contiguousNext))
450
433
  );
451
434
  const previewingNext = Boolean(
452
435
  eligibleNextTransition && time >= blendStart && time < blendEnd
@@ -464,10 +447,15 @@ function VideoFrame({
464
447
  return /* @__PURE__ */ jsxs(
465
448
  "div",
466
449
  {
450
+ ref: recoveryRoot,
467
451
  onErrorCapture: (event) => {
468
452
  const target = event.target;
469
- if ((target instanceof HTMLVideoElement || target instanceof HTMLImageElement) && target.getAttribute("src") === active.scene.variables.mediaUrl && supportsExternalVideoBackdrop(activeTemplate) && (target.closest("[data-layer-scene-id]")?.getAttribute("data-layer-scene-id") === active.scene.id || target.closest("[data-persistent-video-scene-id]")?.getAttribute("data-persistent-video-scene-id") === active.scene.id)) {
470
- markMediaFailed(sceneReadinessKey(active.scene));
453
+ if (!(target instanceof HTMLVideoElement || target instanceof HTMLImageElement)) return;
454
+ const ownerId = target.closest("[data-layer-scene-id]")?.getAttribute("data-layer-scene-id");
455
+ const owner = [active, contiguousNext].find((range) => range?.scene.id === ownerId);
456
+ const template = owner && kit.getTemplate(owner.scene.templateId);
457
+ if (owner && template && supportsExternalVideoBackdrop(template) && target.getAttribute("src") === owner.scene.variables.mediaUrl) {
458
+ markMediaFailed(sceneReadinessKey(owner.scene), "decode-error");
471
459
  }
472
460
  },
473
461
  "data-video-frame": "ready",
@@ -489,7 +477,7 @@ function VideoFrame({
489
477
  scene: active.scene,
490
478
  playing,
491
479
  fallback: activeMediaFailed,
492
- onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene)) : void 0
480
+ onFailure: sceneHasBackdrop(active) && supportsExternalVideoBackdrop(activeTemplate) && !activeMediaFailed ? () => markMediaFailed(sceneReadinessKey(active.scene), "frame-readiness-timeout") : void 0
493
481
  }
494
482
  ),
495
483
  /* @__PURE__ */ jsxs(
@@ -520,58 +508,6 @@ function VideoFrame({
520
508
  }
521
509
  }
522
510
  ),
523
- persistentVideoRange && !persistentVideoFailed && /* @__PURE__ */ jsx2(
524
- "div",
525
- {
526
- "data-persistent-video-scene-id": persistentVideoRange.scene.id,
527
- "aria-hidden": "true",
528
- style: { position: "absolute", inset: 0, zIndex: 0 },
529
- children: /* @__PURE__ */ jsx2(Suspense, { fallback: String(persistentVideoRange.scene.variables.mediaPoster || "") ? /* @__PURE__ */ jsx2(
530
- "img",
531
- {
532
- src: String(persistentVideoRange.scene.variables.mediaPoster),
533
- alt: "",
534
- "data-video-poster-plane": "loading",
535
- "data-video-poster-visible": "true",
536
- style: {
537
- position: "absolute",
538
- inset: 0,
539
- width: "100%",
540
- height: "100%",
541
- objectFit: "cover",
542
- objectPosition: String(persistentVideoRange.scene.variables.mediaPosition || "center")
543
- }
544
- }
545
- ) : null, children: /* @__PURE__ */ jsx2(
546
- SceneVideoBackdrop,
547
- {
548
- mediaUrl: String(persistentVideoRange.scene.variables.mediaUrl || ""),
549
- mediaPoster: String(persistentVideoRange.scene.variables.mediaPoster || "") || void 0,
550
- mediaPosition: String(persistentVideoRange.scene.variables.mediaPosition || "center"),
551
- backgroundEffect: persistentVideoRange.scene.backgroundEffect ?? config.style.defaultBackgroundEffect,
552
- progress: persistentVideoRange.scene.id === active.scene.id ? rawProgress : 0,
553
- sceneDuration: persistentVideoRange.end - persistentVideoRange.start,
554
- isPlaying: persistentVideoRange.scene.id === active.scene.id && playing,
555
- preparingNarration,
556
- muted: mediaAudioMuted || persistentVideoRange.scene.id !== active.scene.id || !playing,
557
- volume: mediaAudioVolume,
558
- playbackId: persistentVideoRange.scene.id,
559
- retainPoster: true,
560
- persistent: true,
561
- preparedPoster: preparedPoster ? {
562
- ...preparedPoster,
563
- // The image is already decoded and costs no second video
564
- // decoder. Fade it above the outgoing video using the same
565
- // authored transition clock, then reuse that exact DOM image
566
- // as the new source's underlay at the cut.
567
- opacity: decoderConstrainedTransition ? blendProgress : 0
568
- } : void 0,
569
- onReady: () => setReadyPersistentVideo(persistentVideoKey),
570
- onError: () => markMediaFailed(persistentVideoKey)
571
- }
572
- ) })
573
- }
574
- ),
575
511
  mountingNext && contiguousNext ? [
576
512
  /* @__PURE__ */ jsx2(
577
513
  SceneLayer,
@@ -580,7 +516,7 @@ function VideoFrame({
580
516
  kit,
581
517
  config,
582
518
  range: active,
583
- onMediaError: () => markMediaFailed(sceneReadinessKey(active.scene)),
519
+ onMediaError: (reason) => markMediaFailed(sceneReadinessKey(active.scene), reason),
584
520
  progress,
585
521
  motionProgress,
586
522
  width: canvas.width,
@@ -593,7 +529,7 @@ function VideoFrame({
593
529
  opacity: 1 - blendProgress,
594
530
  interactive: true,
595
531
  zIndex: 1,
596
- externalVideoBackdrop: activeMediaFailed ? "fallback" : persistentVideoRange?.scene.id === active.scene.id ? persistentVideoMode : false
532
+ externalVideoBackdrop: activeMediaFailed ? "fallback" : false
597
533
  },
598
534
  active.scene.id
599
535
  ),
@@ -615,7 +551,8 @@ function VideoFrame({
615
551
  opacity: blendProgress,
616
552
  interactive: false,
617
553
  zIndex: 2,
618
- externalVideoBackdrop: persistentVideoRange?.scene.id === contiguousNext.scene.id ? persistentVideoMode : false
554
+ onMediaError: (reason) => markMediaFailed(sceneReadinessKey(contiguousNext.scene), reason),
555
+ externalVideoBackdrop: failedMedia.has(sceneReadinessKey(contiguousNext.scene)) ? "fallback" : false
619
556
  },
620
557
  contiguousNext.scene.id
621
558
  )
@@ -627,7 +564,7 @@ function VideoFrame({
627
564
  kit,
628
565
  config,
629
566
  range: active,
630
- onMediaError: () => markMediaFailed(sceneReadinessKey(active.scene)),
567
+ onMediaError: (reason) => markMediaFailed(sceneReadinessKey(active.scene), reason),
631
568
  progress,
632
569
  motionProgress,
633
570
  width: canvas.width,
@@ -640,7 +577,7 @@ function VideoFrame({
640
577
  opacity: 1,
641
578
  interactive: true,
642
579
  zIndex: 1,
643
- externalVideoBackdrop: activeMediaFailed ? "fallback" : persistentVideoRange?.scene.id === active.scene.id ? persistentVideoMode : false
580
+ externalVideoBackdrop: activeMediaFailed ? "fallback" : false
644
581
  },
645
582
  active.scene.id
646
583
  )
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-X4RGAEL5.js";
4
+ } from "./chunk-J7EGPURK.js";
5
5
 
6
6
  // src/visual-system/scene-templates/cinema-media.tsx
7
7
  import { jsx } from "react/jsx-runtime";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SceneBackground,
3
3
  getMediaBackgroundProps
4
- } from "./chunk-X4RGAEL5.js";
4
+ } from "./chunk-J7EGPURK.js";
5
5
  import {
6
6
  editorialFont
7
7
  } from "./chunk-4YM2M62S.js";
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  MediaScene,
3
3
  MediaSceneTemplate
4
- } from "./chunk-PMJNBR4F.js";
5
- import "./chunk-X4RGAEL5.js";
6
- import "./chunk-OBRKB7PL.js";
4
+ } from "./chunk-Q3LPOPHL.js";
5
+ import "./chunk-J7EGPURK.js";
6
+ import "./chunk-5JBMYQP6.js";
7
7
  import "./chunk-224QNWRA.js";
8
- import "./chunk-OOBT4X46.js";
9
8
  export {
10
9
  MediaScene,
11
10
  MediaSceneTemplate
@@ -1,16 +1,15 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-MKMCX3AF.js";
4
- import "./chunk-X4RGAEL5.js";
5
- import "./chunk-OBRKB7PL.js";
3
+ } from "./chunk-R5ZX2LJV.js";
4
+ import "./chunk-J7EGPURK.js";
6
5
  import {
7
6
  editorialLabel,
8
7
  fade
9
8
  } from "./chunk-4YM2M62S.js";
9
+ import "./chunk-5JBMYQP6.js";
10
10
  import {
11
11
  hasSceneMedia
12
12
  } from "./chunk-224QNWRA.js";
13
- import "./chunk-OOBT4X46.js";
14
13
 
15
14
  // src/visual-system/scene-templates/comparison.tsx
16
15
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -1,13 +1,12 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-MKMCX3AF.js";
4
- import "./chunk-X4RGAEL5.js";
5
- import "./chunk-OBRKB7PL.js";
3
+ } from "./chunk-R5ZX2LJV.js";
4
+ import "./chunk-J7EGPURK.js";
6
5
  import {
7
6
  fade
8
7
  } from "./chunk-4YM2M62S.js";
8
+ import "./chunk-5JBMYQP6.js";
9
9
  import "./chunk-224QNWRA.js";
10
- import "./chunk-OOBT4X46.js";
11
10
 
12
11
  // src/visual-system/scene-templates/editorial-timeline.tsx
13
12
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -1,16 +1,15 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-MKMCX3AF.js";
4
- import "./chunk-X4RGAEL5.js";
5
- import "./chunk-OBRKB7PL.js";
3
+ } from "./chunk-R5ZX2LJV.js";
4
+ import "./chunk-J7EGPURK.js";
6
5
  import {
7
6
  editorialLabel,
8
7
  fade
9
8
  } from "./chunk-4YM2M62S.js";
9
+ import "./chunk-5JBMYQP6.js";
10
10
  import {
11
11
  hasSceneMedia
12
12
  } from "./chunk-224QNWRA.js";
13
- import "./chunk-OOBT4X46.js";
14
13
 
15
14
  // src/visual-system/scene-templates/key-figure.tsx
16
15
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -1,15 +1,14 @@
1
1
  import {
2
2
  MediaScene
3
- } from "./chunk-PMJNBR4F.js";
4
- import "./chunk-X4RGAEL5.js";
3
+ } from "./chunk-Q3LPOPHL.js";
5
4
  import {
6
5
  spring
7
- } from "./chunk-OBRKB7PL.js";
6
+ } from "./chunk-J7EGPURK.js";
8
7
  import {
9
8
  editorialFont
10
9
  } from "./chunk-4YM2M62S.js";
10
+ import "./chunk-5JBMYQP6.js";
11
11
  import "./chunk-224QNWRA.js";
12
- import "./chunk-OOBT4X46.js";
13
12
 
14
13
  // src/visual-system/scene-templates/mobile-message.tsx
15
14
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -1,15 +1,14 @@
1
1
  import {
2
2
  EditorialSurface
3
- } from "./chunk-MKMCX3AF.js";
4
- import "./chunk-X4RGAEL5.js";
5
- import "./chunk-OBRKB7PL.js";
3
+ } from "./chunk-R5ZX2LJV.js";
4
+ import "./chunk-J7EGPURK.js";
6
5
  import {
7
6
  fade
8
7
  } from "./chunk-4YM2M62S.js";
8
+ import "./chunk-5JBMYQP6.js";
9
9
  import {
10
10
  hasSceneMedia
11
11
  } from "./chunk-224QNWRA.js";
12
- import "./chunk-OOBT4X46.js";
13
12
 
14
13
  // src/visual-system/scene-templates/quote.tsx
15
14
  import { jsx, jsxs } from "react/jsx-runtime";
package/dist/react.js CHANGED
@@ -40,9 +40,9 @@ import {
40
40
  VideoFrame,
41
41
  getDimensions,
42
42
  sceneReadinessKey
43
- } from "./chunk-24TCMDAA.js";
43
+ } from "./chunk-KUCEOG2L.js";
44
+ import "./chunk-5JBMYQP6.js";
44
45
  import "./chunk-224QNWRA.js";
45
- import "./chunk-OOBT4X46.js";
46
46
  import {
47
47
  getVideoDuration,
48
48
  resolveVideoTimeline
@@ -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-BVQFZL3A.js").then((module) => ({ default: module.MediaSceneTemplate })),
97
+ "cinemaMedia": () => import("./cinema-media-WUS7CKPC.js").then((module) => ({ default: module.MediaSceneTemplate })),
98
98
  "chapterTitle": () => import("./chapter-title-2JVDU62E.js").then((module) => ({ default: module.TitleSceneTemplate })),
99
- "editorialTimeline": () => import("./editorial-timeline-JADHHNEV.js").then((module) => ({ default: module.TimelineSceneTemplate })),
100
- "mobileMessage": () => import("./mobile-message-GADX632R.js").then((module) => ({ default: module.NotificationSceneTemplate })),
101
- "comparison": () => import("./comparison-T27C7FSV.js").then((module) => ({ default: module.ComparisonSceneTemplate })),
102
- "quote": () => import("./quote-FRCEC34C.js").then((module) => ({ default: module.QuoteSceneTemplate })),
103
- "keyFigure": () => import("./key-figure-TZQWPFMI.js").then((module) => ({ default: module.KeyFigureSceneTemplate }))
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 }))
104
104
  };
105
105
 
106
106
  // src/visual-system/catalog/builtin-player.generated.ts
package/dist/server.js CHANGED
@@ -895,6 +895,21 @@ var object = (value) => value && typeof value === "object" && !Array.isArray(val
895
895
  function text(value, maximum) {
896
896
  return typeof value === "string" && value.trim().length <= maximum ? value.trim() : "";
897
897
  }
898
+ function readStockSelection(value) {
899
+ const item = object(value);
900
+ const phrase = (candidate) => {
901
+ if (typeof candidate !== "string") return;
902
+ const normalized = candidate.trim().replace(/\s+/gu, " ");
903
+ if (!normalized || normalized.length > 48 || !/^[\p{L}\p{N} '’-]+$/u.test(normalized)) return;
904
+ const words = normalized.match(/[\p{L}\p{N}]+/gu) ?? [];
905
+ return words.length >= 1 && words.length <= 4 ? normalized : void 0;
906
+ };
907
+ const subject = phrase(item?.subject);
908
+ if (!subject) return;
909
+ const activity = phrase(item?.activity), equipment = phrase(item?.equipment);
910
+ const exclude = Array.isArray(item?.exclude) && item.exclude.length <= 3 ? item.exclude.map(phrase).filter((value2) => value2 !== void 0) : [];
911
+ return { subject, ...activity ? { activity } : {}, ...equipment ? { equipment } : {}, ...exclude.length ? { exclude } : {} };
912
+ }
898
913
  function chapterSubject(subject) {
899
914
  const normalized = subject.replace(/\s+/gu, " ");
900
915
  if (normalized.length <= 65) return normalized;
@@ -912,6 +927,7 @@ function readShot(value, clipDurationSec, answerSubject = "") {
912
927
  return {
913
928
  narration,
914
929
  title,
930
+ stockSelection: readStockSelection(item?.stockSelection),
915
931
  subject,
916
932
  action: text(item?.action, 600),
917
933
  durationSec: typeof item?.durationSec === "number" && Number.isFinite(item.durationSec) ? Math.min(clipDurationSec, Math.max(2, item.durationSec)) : clipDurationSec,
@@ -964,7 +980,7 @@ function createChatShotPlanner(options) {
964
980
  return { type: "scene.add", ...closer ? { placement: "closer" } : {}, scene: {
965
981
  id: `${context.request.requestId}-shot-${++index}`,
966
982
  templateId: "cinemaMedia",
967
- variables: { fallbackText: shot.title, mediaType: "video", mediaKeyword: shot.subject, shotDirection: [
983
+ variables: { ...options.mode === "pexels" && shot.stockSelection ? { stockSelection: shot.stockSelection } : {}, fallbackText: shot.title, mediaType: "video", mediaKeyword: shot.subject, shotDirection: [
968
984
  brief?.visualDirection,
969
985
  shot.action,
970
986
  shot.continuity === "continue" ? "Continue the established subject, setting and action consistently." : "A deliberate new shot; choose framing that reveals this beat.",
@@ -1132,8 +1148,10 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
1132
1148
  `First write one brief: {"type":"answer","intent":"explanation|story|comedy|imagination|practical","opening":"a short inviting spoken introduction of 6\u20139 words","subject":"literal visual subject","development":"the essential development of this answer","visualDirection":"consistent subjects, appearance and visual approach","ending":{"title":"short meaningful chapter title, at most 65 characters","narration":"the authored payoff","subject":"literal subject","action":"visible action or change","durationSec":${clipDurationSec},"continuity":"cut|continue"}}.`,
1133
1149
  `Then stream each developing shot on its own line: {"type":"shot","title":"short meaningful chapter title, at most 65 characters","narration":"the exact spoken beat","subject":"2\u20138 literal filmable words, at most 80 characters","action":"concrete subject, action or visible change and useful framing","durationSec":${clipDurationSec},"continuity":"cut|continue"}.`,
1134
1150
  `The selected footage mode is ${mode === "pexels" ? "Pexels stock search: use literal filmable subjects; never imply stock proves a mechanism or depicts fictional events exactly" : `AI video, with at most ${generatedVideoAvailable ? maxGeneratedVideos : 0} generation attempts`}. Missing footage becomes the authored chapter title, with complete narration. Preserve the full answer rather than shortening it to fit credits. The host selects providers; do not make source choices.`,
1151
+ ...mode === "pexels" ? ["Stock queries must retain the essential subject, activity and distinguishing equipment in the shot's subject field, within its word limit. That field alone is the search query; action and visualDirection do not refine it. Prefer common observable actions with usable framing. Do not replace the required actor or activity with scenery, a different sport or a loosely related setting. Preserve fictional or comic narration, but do not depend on stock showing an exact invented expression or sequence; choose an illustrative action that supports the beat."] : [],
1152
+ ...mode === "pexels" ? ['Include stockSelection on every shot and the saved ending when the essential subject is known: "stockSelection":{"subject":"essential actor or object category","activity":"optional literal activity","equipment":"optional distinguishing equipment","exclude":["optional contradictory subject or activity"]}. Each phrase must be 1\u20134 words and at most 48 characters; exclude has at most 3 phrases. The essential subject is separate from the setting: do not use scenery, mood, camera framing or incidental appearance as the actor. Keep the search query broad enough to find footage; the optional hint helps select results without substituting a different actor or task. Use exclusions only for actual contradictions, not every detail absent from the story. Omit unknown fields or the whole hint rather than inventing an anchor. This is selection guidance, not verification that footage depicts the exact narration.'] : [],
1135
1153
  "For a very short answer whose ending alone fulfills the request, development may be empty and no developing shots are needed. Otherwise, develop the essential content before the ending.",
1136
- "The brief's ending is saved and played after your developing shots. Do not repeat it as a shot. Stop writing after the last developing shot. No technical events, identifiers, template choices, media providers, URLs or extra fields.",
1154
+ "The brief's ending is saved and played after your developing shots. Do not repeat it as a shot. Stop writing after the last developing shot. No technical events, identifiers, template choices, media providers, URLs or unlisted fields.",
1137
1155
  "Every shot uses moving footage with separate narration and subtitles. Generated footage is silent: do not ask its subjects to speak or render words. No headline cards or on-screen explanatory text.",
1138
1156
  `Each clip has at most ${clipDurationSec} seconds. Write spoken beats that fit naturally, usually ${Math.floor(clipDurationSec * 1.6)}\u2013${Math.floor(clipDurationSec * 2)} words per shot. Split longer ideas across purposeful shots, preserving facts and qualifiers. Never truncate a claim to meet a word target. Use only the shots needed within the total duration, including the ending; do not pad to a fixed count.`,
1139
1157
  "Identify the full answer and its ending before developing shots. Each action must support what is said: camera movement alone is not progression. Vary scale, viewpoint and meaningful details while keeping subjects consistent.",
@@ -1141,7 +1159,7 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
1141
1159
  "Stories: portray characters making choices and experiencing consequences; use consistent character descriptions and an earned resolution, not a promised next scene.",
1142
1160
  "Comedy: establish the premise, time the visual or spoken reveal, allow a reaction beat, and stop on the payoff without explaining the joke.",
1143
1161
  "Imagination: make the impossible action concrete, establish the world's internal rules and keep its imagery consistent. Do not replace imagination with an explanation of it.",
1144
- "Practical answers: show usable actions in their necessary order, with framing that makes the method and result visible. Preserve essential steps and relevant safety conditions.",
1162
+ "Practical answers: show usable actions in their necessary order, with framing that makes the method and result visible. Preserve essential steps and relevant safety conditions. Match the requested experience level. For beginners, explain an unavoidable technical term in ordinary words or replace it with an observable action. Make the essential setup and a useful success cue explicit. Qualify advice that depends on equipment, task or conditions instead of presenting one setup as universal. Never add unsupported precision merely to sound instructional.",
1145
1163
  openingAlreadyProvided ? "The supplied opening has already been spoken. Preserve it and begin the body with new content." : "The brief opening is spoken during preparation. The first body shot must develop it rather than repeat its words or claim.",
1146
1164
  "Use continuity=continue when the same subject/action should remain coherent; choose cut for a purposeful new view. Describe recurring subjects consistently. Never assume a different angle or generated depiction proves a factual claim."
1147
1165
  ].join("\n");
@@ -1630,6 +1648,7 @@ function createVideoChatHandler(options) {
1630
1648
  invalidPartBehavior: videoOptions.invalidPartBehavior,
1631
1649
  requireCloser: options.requireCloser ?? true,
1632
1650
  generate: createChatShotPlanner({
1651
+ mode,
1633
1652
  streamText: (context) => {
1634
1653
  lifecycle = getGenerationLifecycleSink(context);
1635
1654
  return videoOptions.streamText(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillaskyai/video",
3
- "version": "0.10.7",
3
+ "version": "0.10.9",
4
4
  "description": "Open-source voice-and-video chat SDK for AI applications.",
5
5
  "keywords": [
6
6
  "video-chat",
@@ -28,7 +28,7 @@
28
28
  "path": "src/visual-system/scene-templates/external-video-backdrop.tsx",
29
29
  "type": "registry:lib",
30
30
  "target": "vanillasky/scene-templates/external-video-backdrop.tsx",
31
- "content": "import React from \"react\";\n\n// Source-owned templates may live in a consumer's tree while VideoFrame comes\n// from the package. Both copies must observe the same internal context or the\n// consumer template would mount a second video over the player-owned plane,\n// and would never inherit the player's native media audio state.\nexport type ExternalVideoBackdropMode = false | \"pending\" | \"ready\" | \"fallback\";\n\ninterface BackdropContextValue {\n mode: ExternalVideoBackdropMode;\n audioMuted: boolean;\n audioVolume: number;\n preparingNarration?: boolean;\n onMediaError?: () => void;\n}\n\nconst DEFAULT: BackdropContextValue = { mode: false, audioMuted: true, audioVolume: 1 };\n\nconst sharedContext = globalThis as typeof globalThis & {\n __vanillaskyVideoBackdropContext?: React.Context<BackdropContextValue>;\n};\nconst BackdropContext = sharedContext.__vanillaskyVideoBackdropContext\n ??= React.createContext<BackdropContextValue>(DEFAULT);\n\nexport function ExternalVideoBackdropProvider({\n mode,\n audioMuted = true,\n audioVolume = 1,\n preparingNarration = false,\n onMediaError,\n children,\n}: {\n mode: ExternalVideoBackdropMode;\n audioMuted?: boolean;\n audioVolume?: number;\n preparingNarration?: boolean;\n onMediaError?: () => void;\n children: React.ReactNode;\n}) {\n const value = React.useMemo(\n () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),\n [mode, audioMuted, audioVolume, preparingNarration, onMediaError],\n );\n return (\n <BackdropContext.Provider value={value}>\n {children}\n </BackdropContext.Provider>\n );\n}\n\nexport function useExternalVideoBackdrop(): ExternalVideoBackdropMode {\n return React.useContext(BackdropContext).mode;\n}\n\nexport function useMediaAudio(): { muted: boolean; volume: number } {\n const { audioMuted, audioVolume } = React.useContext(BackdropContext);\n return { muted: audioMuted, volume: audioVolume };\n}\n\n/** Internal first-frame priming state; an explicit viewer pause never sets it. */\nexport function useNarrationPreroll(): boolean {\n return React.useContext(BackdropContext).preparingNarration === true;\n}\n\n/** Routes local decoder/playback failures to the scene-owned recovery surface. */\nexport function useMediaFailure(): (() => void) | undefined {\n return React.useContext(BackdropContext).onMediaError;\n}\n"
31
+ "content": "import React from \"react\";\n\n// Source-owned templates may live in a consumer's tree while VideoFrame comes\n// from the package. Both copies must observe the same internal context or the\n// consumer template would mount a second video over the player-owned plane,\n// and would never inherit the player's native media audio state.\nexport type ExternalVideoBackdropMode = false | \"pending\" | \"ready\" | \"fallback\";\n\nexport type MediaRecoveryReason = \"decode-error\" | \"frame-readiness-timeout\" | \"stalled-media\" | \"playback-error\";\n\ninterface BackdropContextValue {\n mode: ExternalVideoBackdropMode;\n audioMuted: boolean;\n audioVolume: number;\n preparingNarration?: boolean;\n onMediaError?: (reason?: MediaRecoveryReason) => void;\n}\n\nconst DEFAULT: BackdropContextValue = { mode: false, audioMuted: true, audioVolume: 1 };\n\nconst sharedContext = globalThis as typeof globalThis & {\n __vanillaskyVideoBackdropContext?: React.Context<BackdropContextValue>;\n};\nconst BackdropContext = sharedContext.__vanillaskyVideoBackdropContext\n ??= React.createContext<BackdropContextValue>(DEFAULT);\n\nexport function ExternalVideoBackdropProvider({\n mode,\n audioMuted = true,\n audioVolume = 1,\n preparingNarration = false,\n onMediaError,\n children,\n}: {\n mode: ExternalVideoBackdropMode;\n audioMuted?: boolean;\n audioVolume?: number;\n preparingNarration?: boolean;\n onMediaError?: (reason?: MediaRecoveryReason) => void;\n children: React.ReactNode;\n}) {\n const value = React.useMemo(\n () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),\n [mode, audioMuted, audioVolume, preparingNarration, onMediaError],\n );\n return (\n <BackdropContext.Provider value={value}>\n {children}\n </BackdropContext.Provider>\n );\n}\n\nexport function useExternalVideoBackdrop(): ExternalVideoBackdropMode {\n return React.useContext(BackdropContext).mode;\n}\n\nexport function useMediaAudio(): { muted: boolean; volume: number } {\n const { audioMuted, audioVolume } = React.useContext(BackdropContext);\n return { muted: audioMuted, volume: audioVolume };\n}\n\n/** Internal first-frame priming state; an explicit viewer pause never sets it. */\nexport function useNarrationPreroll(): boolean {\n return React.useContext(BackdropContext).preparingNarration === true;\n}\n\n/** Routes local decoder/playback failures to the scene-owned recovery surface. */\nexport function useMediaFailure(): ((reason?: MediaRecoveryReason) => void) | undefined {\n return React.useContext(BackdropContext).onMediaError;\n}\n"
32
32
  },
33
33
  {
34
34
  "path": "src/visual-system/scene-templates/media-position.ts",
@@ -40,7 +40,7 @@
40
40
  "path": "src/visual-system/scene-templates/scene-video-backdrop.tsx",
41
41
  "type": "registry:lib",
42
42
  "target": "vanillasky/scene-templates/scene-video-backdrop.tsx",
43
- "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n retainPoster?: boolean;\n persistent?: boolean;\n preparedPoster?: {\n presentationKey: string;\n mediaPoster: string;\n mediaPosition: string;\n backgroundEffect?: string;\n /** Existing global transition progress. On decoder-constrained Safari,\n * this fades the decoded incoming still above the outgoing video before\n * the single video element changes source. */\n opacity?: number;\n };\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n retainPoster = false,\n persistent = false,\n preparedPoster,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const presentedVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = () => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.();\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {\n awaitingFirstFrame = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // Only a source that has presented a frame can be judged as stalled motion.\n let deadline = setTimeout(fail, awaitingFirstFrame ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(unavailable);\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(unavailable);\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(unavailable);\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n zIndex: persistent ? 1 : undefined,\n };\n const preparedPosition = preparedPoster\n ? resolveMediaPosition(preparedPoster.mediaPosition)\n : resolvedPosition;\n const preparedTransform = getBackgroundTransform(preparedPoster?.backgroundEffect, 0, 0);\n const posterPlanes = [\n ...(persistent && mediaPoster ? [{\n presentationKey: videoPresentationKey,\n mediaPoster,\n mediaPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n opacity: 1,\n zIndex: 0,\n role: \"current\",\n }] : []),\n ...(preparedPoster && preparedPoster.presentationKey !== videoPresentationKey ? [{\n presentationKey: preparedPoster.presentationKey,\n mediaPoster: preparedPoster.mediaPoster,\n mediaPosition: preparedPosition,\n transform: preparedTransform.transform,\n transformOrigin: preparedTransform.transformOrigin,\n opacity: preparedPoster.opacity ?? 0,\n zIndex: 2,\n role: \"prepared\",\n }] : []),\n ];\n\n return (\n <>\n {posterPlanes.map((posterPlane) => (\n <img\n key={posterPlane.presentationKey}\n src={posterPlane.mediaPoster}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-video-poster-plane={posterPlane.role}\n data-video-poster-visible={posterPlane.opacity > 0 ? \"true\" : \"false\"}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: posterPlane.mediaPosition,\n transform: posterPlane.transform,\n transformOrigin: posterPlane.transformOrigin,\n zIndex: posterPlane.zIndex,\n opacity: posterPlane.opacity,\n pointerEvents: \"none\",\n }}\n />\n ))}\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={retainPoster || decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onLoadedData={(event) => {\n const video = event.currentTarget;\n const markPresented = () => {\n if (!video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return;\n presentedVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReady?.();\n if (!retainPoster) setDecodedVideoUrl(mediaUrl);\n };\n if (video.requestVideoFrameCallback) {\n video.requestVideoFrameCallback(markPresented);\n return;\n }\n markPresented();\n }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop={persistent ? \"persistent\" : \"scene\"}\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
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"
44
44
  },
45
45
  {
46
46
  "path": "src/visual-system/scene-templates/color-utils.ts",
@@ -49,15 +49,11 @@ call. A template introduction starts while footage prepares; body shots contain
49
49
  moving footage, narration and subtitles. Intent changes the visible actions and
50
50
  pacing, not the rendering pipeline.
51
51
 
52
- The SDK tries generated footage first, then relevant approved stock when the
53
- provider is unavailable, denied, or fails. Attempts, including failures, share
54
- the host's `maxGeneratedVideos` ceiling. A stock miss never broadens the subject
55
- automatically. If no relevant footage is available, narration and subtitles
56
- continue as authored chapter scenes.
57
-
58
52
  Choose AI video or Pexels in Settings. Each mode uses only its selected footage
59
- provider, and both use chapter scenes when footage cannot be prepared. The
60
- Pexels adapter searches the full catalog with bounded subject matching,
53
+ provider, and both use chapter scenes when footage cannot be prepared. AI video
54
+ attempts, including failures, share the host's `maxGeneratedVideos` ceiling.
55
+ Exhausted allowance recovers directly to authored chapters without stock calls.
56
+ The Pexels adapter searches the full catalog with bounded subject matching,
61
57
  orientation selection and caching; it no longer requires a reviewed index.
62
58
  Custom interfaces must display a prominent [Pexels](https://www.pexels.com) credit.
63
59
 
@@ -70,3 +66,26 @@ work and playback, and failed media becomes the authored chapter.
70
66
  The provider names its own model. Override the tested defaults with
71
67
  `ANTHROPIC_PLANNER_MODEL`, `ANTHROPIC_NARRATION_MODEL`, or `FAL_VIDEO_MODEL`
72
68
  when needed.
69
+
70
+ ### Stock selection hints
71
+
72
+ In Pexels mode, the same planning stream can supply an optional
73
+ `scene.variables.stockSelection` to the application's media resolver:
74
+
75
+ ```ts
76
+ { subject: "cyclist", activity: "riding", equipment: "bicycle", exclude: ["motorcycle"] }
77
+ ```
78
+
79
+ `subject` names the essential actor or object separately from the search query's
80
+ setting. `activity`, `equipment` and `exclude` are optional. Each phrase has
81
+ one to four words and at most 48 characters; `exclude` has at most three phrases.
82
+ The SDK validates these fields, drops unknown keys and invalid optional values,
83
+ and omits the whole hint when the essential subject is missing or invalid.
84
+ It never guesses that subject from the first query word.
85
+
86
+ Adapters can use this hint to prefer matching subjects and reject explicitly
87
+ contradictory metadata while keeping the query broad enough for catalog search.
88
+ Missing metadata remains uncertain, not proof of a match. The hint does not
89
+ verify the depicted action or factual correctness. It is omitted from AI-video
90
+ requests and removed before scenes are emitted or persisted; it adds no model
91
+ request or public scene field.