@vanillaskyai/video 0.10.8 → 0.10.10
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 +14 -0
- package/dist/check-runtime.js +1 -1
- package/dist/{chunk-Q3LPOPHL.js → chunk-3S5FXE6G.js} +1 -1
- package/dist/{chunk-KUCEOG2L.js → chunk-FFKAHNYD.js} +6 -2
- package/dist/{chunk-R5ZX2LJV.js → chunk-XL3H42K5.js} +1 -1
- package/dist/{chunk-J7EGPURK.js → chunk-YERSNTFL.js} +12 -8
- package/dist/{cinema-media-WUS7CKPC.js → cinema-media-IAK3LC2J.js} +2 -2
- package/dist/{comparison-T4OYCOH3.js → comparison-MCTN376M.js} +2 -2
- package/dist/{editorial-timeline-JHQZIPNE.js → editorial-timeline-435UCJ45.js} +2 -2
- package/dist/{key-figure-YWN2OBQU.js → key-figure-BSOQ3TYN.js} +2 -2
- package/dist/{mobile-message-CDKOERRZ.js → mobile-message-GVFA56QP.js} +2 -2
- package/dist/{quote-JKABOMIG.js → quote-H2X4XGPB.js} +2 -2
- package/dist/react.js +7 -7
- package/dist/server.js +57 -8
- package/package.json +1 -1
- package/registry/items/backgrounds.json +1 -1
- package/starters/video-chat/README.md +23 -0
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/server.ts +1 -1
- package/starters/video-chat/stock.ts +27 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.10.10
|
|
8
|
+
|
|
9
|
+
- Accept multiline streamed chat JSON objects without waiting for the entire answer, while retaining bounded parsing and strict scene validation.
|
|
10
|
+
|
|
11
|
+
- Wait for a presented frame with playable future data before starting narration, retaining bounded cold startup and quick recovery for footage that stalls after playback was available.
|
|
12
|
+
|
|
13
|
+
## 0.10.9
|
|
14
|
+
|
|
15
|
+
- Use essential subject hints and explicit exclusions when selecting starter Pexels footage, and isolate cached selections by those hints. Metadata-free results remain unverified provider-ranked fallbacks.
|
|
16
|
+
|
|
17
|
+
- Forward bounded optional subject/activity hints to Pexels resolvers from the existing planning stream, without adding fields to emitted scenes or AI-video requests.
|
|
18
|
+
|
|
19
|
+
- Clarify beginner instructions and condition-dependent advice, and retain essential subjects and activities in Pexels search planning.
|
|
20
|
+
|
|
7
21
|
## 0.10.8
|
|
8
22
|
|
|
9
23
|
- Reassert a requested pause if native video playback starts late, preserving footage during delayed narration and keeping viewer pauses in place.
|
package/dist/check-runtime.js
CHANGED
|
@@ -61,14 +61,18 @@ function MountedSceneReadiness({ scene, playing, fallback = false, onFailure })
|
|
|
61
61
|
}
|
|
62
62
|
if (isVideo) {
|
|
63
63
|
const video = layer.querySelector("video");
|
|
64
|
-
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >=
|
|
64
|
+
if (video && video.getAttribute("src") === mediaUrl && video.currentSrc === video.src && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
65
65
|
if (presented === video) {
|
|
66
66
|
finish(void 0, true);
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
69
|
observed = video;
|
|
70
70
|
if (video.requestVideoFrameCallback) {
|
|
71
|
-
callback = video.requestVideoFrameCallback(() =>
|
|
71
|
+
callback = video.requestVideoFrameCallback(() => {
|
|
72
|
+
callback = void 0;
|
|
73
|
+
presented = video;
|
|
74
|
+
check();
|
|
75
|
+
});
|
|
72
76
|
return;
|
|
73
77
|
}
|
|
74
78
|
finish(void 0, true);
|
|
@@ -257,7 +257,7 @@ var SceneVideoBackdrop = ({
|
|
|
257
257
|
const [waitingKey, setWaitingKey] = useState();
|
|
258
258
|
const [exhaustedKey, setExhaustedKey] = useState();
|
|
259
259
|
const videoRef = useRef(null);
|
|
260
|
-
const
|
|
260
|
+
const playableVideoUrl = useRef(void 0);
|
|
261
261
|
const startedVideoUrl = useRef(void 0);
|
|
262
262
|
const startedPlaybackId = useRef(void 0);
|
|
263
263
|
const videoPresentationKey = `${playbackId}\0${mediaUrl}`;
|
|
@@ -278,7 +278,7 @@ var SceneVideoBackdrop = ({
|
|
|
278
278
|
const video = videoRef.current;
|
|
279
279
|
if (!video) return;
|
|
280
280
|
const expectedSource = video.getAttribute("src") === mediaUrl ? video.src : void 0;
|
|
281
|
-
let
|
|
281
|
+
let awaitingPlayback = playableVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;
|
|
282
282
|
let previousTime = video.currentTime;
|
|
283
283
|
let forwardFrames = 0;
|
|
284
284
|
let stopped = false;
|
|
@@ -287,8 +287,9 @@ var SceneVideoBackdrop = ({
|
|
|
287
287
|
const observe = (_now, metadata) => {
|
|
288
288
|
if (stopped) return;
|
|
289
289
|
const currentSource = video.currentSrc === expectedSource;
|
|
290
|
-
if (currentSource &&
|
|
291
|
-
|
|
290
|
+
if (currentSource && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
|
291
|
+
playableVideoUrl.current = mediaUrl;
|
|
292
|
+
awaitingPlayback = false;
|
|
292
293
|
clearTimeout(deadline);
|
|
293
294
|
deadline = setTimeout(fail, 1e3);
|
|
294
295
|
}
|
|
@@ -308,10 +309,10 @@ var SceneVideoBackdrop = ({
|
|
|
308
309
|
const fail = () => {
|
|
309
310
|
if (!stopped) {
|
|
310
311
|
stopped = true;
|
|
311
|
-
unavailable(
|
|
312
|
+
unavailable(awaitingPlayback ? "frame-readiness-timeout" : "stalled-media");
|
|
312
313
|
}
|
|
313
314
|
};
|
|
314
|
-
let deadline = setTimeout(fail,
|
|
315
|
+
let deadline = setTimeout(fail, awaitingPlayback ? 8e3 : 1e3);
|
|
315
316
|
observe();
|
|
316
317
|
return () => {
|
|
317
318
|
stopped = true;
|
|
@@ -329,7 +330,7 @@ var SceneVideoBackdrop = ({
|
|
|
329
330
|
let frame;
|
|
330
331
|
const markPresented = () => {
|
|
331
332
|
if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey || video.getAttribute("src") !== mediaUrl || video.currentSrc !== video.src) return false;
|
|
332
|
-
|
|
333
|
+
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;
|
|
333
334
|
video.dispatchEvent(new Event("vanillasky:video-frame-presented", { bubbles: true }));
|
|
334
335
|
onReadyRef.current?.();
|
|
335
336
|
setDecodedVideoUrl(mediaUrl);
|
|
@@ -449,7 +450,10 @@ var SceneVideoBackdrop = ({
|
|
|
449
450
|
onLoadedMetadata: (event) => fitDuration(event.currentTarget),
|
|
450
451
|
onEnded: (event) => continueMotion(event.currentTarget),
|
|
451
452
|
onPlay: enforceRequestedPause,
|
|
452
|
-
onPlaying:
|
|
453
|
+
onPlaying: (event) => {
|
|
454
|
+
if (event.currentTarget.currentSrc === event.currentTarget.src && event.currentTarget.getAttribute("src") === mediaUrl && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;
|
|
455
|
+
enforceRequestedPause(event);
|
|
456
|
+
},
|
|
453
457
|
onWaiting: () => {
|
|
454
458
|
if (isPlaying) setWaitingKey(videoPresentationKey);
|
|
455
459
|
},
|
package/dist/react.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
VideoFrame,
|
|
41
41
|
getDimensions,
|
|
42
42
|
sceneReadinessKey
|
|
43
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-FFKAHNYD.js";
|
|
44
44
|
import "./chunk-5JBMYQP6.js";
|
|
45
45
|
import "./chunk-224QNWRA.js";
|
|
46
46
|
import {
|
|
@@ -94,13 +94,13 @@ import { createElement } from "react";
|
|
|
94
94
|
|
|
95
95
|
// src/visual-system/catalog/builtin-loaders.generated.ts
|
|
96
96
|
var GENERATED_BUILTIN_TEMPLATE_LOADERS = {
|
|
97
|
-
"cinemaMedia": () => import("./cinema-media-
|
|
97
|
+
"cinemaMedia": () => import("./cinema-media-IAK3LC2J.js").then((module) => ({ default: module.MediaSceneTemplate })),
|
|
98
98
|
"chapterTitle": () => import("./chapter-title-2JVDU62E.js").then((module) => ({ default: module.TitleSceneTemplate })),
|
|
99
|
-
"editorialTimeline": () => import("./editorial-timeline-
|
|
100
|
-
"mobileMessage": () => import("./mobile-message-
|
|
101
|
-
"comparison": () => import("./comparison-
|
|
102
|
-
"quote": () => import("./quote-
|
|
103
|
-
"keyFigure": () => import("./key-figure-
|
|
99
|
+
"editorialTimeline": () => import("./editorial-timeline-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 }))
|
|
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.",
|
|
@@ -1001,23 +1017,53 @@ function createChatShotPlanner(options) {
|
|
|
1001
1017
|
bodyDuration += shot.durationSec;
|
|
1002
1018
|
return scenePart(shot);
|
|
1003
1019
|
};
|
|
1020
|
+
let cursor = 0, depth = 0, quoted = false, escaped = false;
|
|
1021
|
+
const takeFrame = () => {
|
|
1022
|
+
if (cursor === 0) {
|
|
1023
|
+
buffer = buffer.trimStart();
|
|
1024
|
+
if (!buffer) return;
|
|
1025
|
+
if (buffer[0] !== "{" && buffer[0] !== "[") {
|
|
1026
|
+
const newline = buffer.indexOf("\n");
|
|
1027
|
+
if (newline < 0) return;
|
|
1028
|
+
const raw = buffer.slice(0, newline);
|
|
1029
|
+
buffer = buffer.slice(newline + 1);
|
|
1030
|
+
return raw;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
for (; cursor < buffer.length; cursor++) {
|
|
1034
|
+
const character = buffer[cursor];
|
|
1035
|
+
if (quoted) {
|
|
1036
|
+
if (escaped) escaped = false;
|
|
1037
|
+
else if (character === "\\") escaped = true;
|
|
1038
|
+
else if (character === '"') quoted = false;
|
|
1039
|
+
} else if (character === '"') quoted = true;
|
|
1040
|
+
else if (character === "{" || character === "[") depth++;
|
|
1041
|
+
else if (character === "}" || character === "]") {
|
|
1042
|
+
depth--;
|
|
1043
|
+
if (depth === 0) {
|
|
1044
|
+
const raw = buffer.slice(0, cursor + 1);
|
|
1045
|
+
buffer = buffer.slice(cursor + 1);
|
|
1046
|
+
cursor = 0;
|
|
1047
|
+
return raw;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1004
1052
|
try {
|
|
1005
1053
|
for await (const delta of upstream) {
|
|
1006
1054
|
context.signal.throwIfAborted();
|
|
1007
1055
|
if (typeof delta !== "string") throw new Error("The LLM adapter returned a non-text delta");
|
|
1008
1056
|
buffer += delta;
|
|
1009
1057
|
if (buffer.length > 32768) throw new Error("Chat plan line exceeds the bounded stream limit");
|
|
1010
|
-
let
|
|
1011
|
-
while (
|
|
1012
|
-
const raw = buffer.slice(0, newline);
|
|
1013
|
-
buffer = buffer.slice(newline + 1);
|
|
1058
|
+
let raw = takeFrame();
|
|
1059
|
+
while (raw !== void 0) {
|
|
1014
1060
|
try {
|
|
1015
1061
|
const part = line(raw);
|
|
1016
1062
|
if (part) yield JSON.stringify(part) + "\n";
|
|
1017
1063
|
} catch (cause) {
|
|
1018
1064
|
reject(cause);
|
|
1019
1065
|
}
|
|
1020
|
-
|
|
1066
|
+
raw = takeFrame();
|
|
1021
1067
|
}
|
|
1022
1068
|
}
|
|
1023
1069
|
if (buffer.trim()) {
|
|
@@ -1132,8 +1178,10 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
|
|
|
1132
1178
|
`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
1179
|
`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
1180
|
`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.`,
|
|
1181
|
+
...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."] : [],
|
|
1182
|
+
...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
1183
|
"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
|
|
1184
|
+
"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
1185
|
"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
1186
|
`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
1187
|
"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 +1189,7 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
|
|
|
1141
1189
|
"Stories: portray characters making choices and experiencing consequences; use consistent character descriptions and an earned resolution, not a promised next scene.",
|
|
1142
1190
|
"Comedy: establish the premise, time the visual or spoken reveal, allow a reaction beat, and stop on the payoff without explaining the joke.",
|
|
1143
1191
|
"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.",
|
|
1192
|
+
"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
1193
|
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
1194
|
"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
1195
|
].join("\n");
|
|
@@ -1630,6 +1678,7 @@ function createVideoChatHandler(options) {
|
|
|
1630
1678
|
invalidPartBehavior: videoOptions.invalidPartBehavior,
|
|
1631
1679
|
requireCloser: options.requireCloser ?? true,
|
|
1632
1680
|
generate: createChatShotPlanner({
|
|
1681
|
+
mode,
|
|
1633
1682
|
streamText: (context) => {
|
|
1634
1683
|
lifecycle = getGenerationLifecycleSink(context);
|
|
1635
1684
|
return videoOptions.streamText(context);
|
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"path": "src/visual-system/scene-templates/scene-video-backdrop.tsx",
|
|
41
41
|
"type": "registry:lib",
|
|
42
42
|
"target": "vanillasky/scene-templates/scene-video-backdrop.tsx",
|
|
43
|
-
"content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const presentedVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n if (!isPlaying || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingFirstFrame = presentedVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingFirstFrame && (metadata || presentedVideoUrl.current === mediaUrl)) {\n awaitingFirstFrame = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(awaitingFirstFrame ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // Only a source that has presented a frame can be judged as stalled motion.\n let deadline = setTimeout(fail, awaitingFirstFrame ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying]);\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let stopped = false;\n let frame: number | undefined;\n const markPresented = () => {\n if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return false;\n presentedVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReadyRef.current?.();\n setDecodedVideoUrl(mediaUrl);\n stopped = true;\n return true;\n };\n const observe = () => {\n if (stopped || frame !== undefined) return;\n if (video.requestVideoFrameCallback) {\n frame = video.requestVideoFrameCallback(() => {\n frame = undefined;\n if (!markPresented()) observe();\n });\n } else if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markPresented();\n };\n // A cached resource can finish loading while its Suspense tree is still\n // detached. Observe the mounted frame even if loadeddata was missed.\n video.addEventListener(\"loadeddata\", observe);\n observe();\n return () => {\n stopped = true;\n video.removeEventListener(\"loadeddata\", observe);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [mediaUrl, videoPresentationKey]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(() => unavailable());\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n if (rewindPreroll && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(() => unavailable());\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(() => unavailable());\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const enforceRequestedPause = (event: React.SyntheticEvent<HTMLVideoElement>) => {\n // WebKit may enter playback without a playing event while seeking or\n // waiting. Both native start events must honor the latest requested hold.\n if (presentationRef.current.playing) return;\n event.currentTarget.pause();\n if (rewindPreroll && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;\n };\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n };\n return (\n <>\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n loop={false}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onPlay={enforceRequestedPause}\n onPlaying={enforceRequestedPause}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop=\"scene\"\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
|
|
43
|
+
"content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const playableVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n 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"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"path": "src/visual-system/scene-templates/color-utils.ts",
|
|
@@ -66,3 +66,26 @@ work and playback, and failed media becomes the authored chapter.
|
|
|
66
66
|
The provider names its own model. Override the tested defaults with
|
|
67
67
|
`ANTHROPIC_PLANNER_MODEL`, `ANTHROPIC_NARRATION_MODEL`, or `FAL_VIDEO_MODEL`
|
|
68
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.
|
|
@@ -37,7 +37,7 @@ export const handleVideoChat = createVideoChatHandler({
|
|
|
37
37
|
return text;
|
|
38
38
|
},
|
|
39
39
|
searchMedia: process.env.PEXELS_API_KEY
|
|
40
|
-
? (query, { orientation, signal }) => findStockFootage(query, orientation, signal)
|
|
40
|
+
? (query, { orientation, signal, scene }) => findStockFootage(query, orientation, signal, scene?.variables.stockSelection)
|
|
41
41
|
: undefined,
|
|
42
42
|
...providers,
|
|
43
43
|
welcome: {
|
|
@@ -9,11 +9,27 @@ interface PexelsVideo {
|
|
|
9
9
|
url?: string;
|
|
10
10
|
image?: string;
|
|
11
11
|
title?: unknown;
|
|
12
|
+
description?: unknown;
|
|
12
13
|
tags?: unknown;
|
|
13
14
|
video_files?: { link?: string; width?: number; height?: number; file_type?: string }[];
|
|
14
15
|
}
|
|
15
16
|
const cache = new Map<string, { expires: number; media: StockVideo | null }>();
|
|
16
17
|
const words = (value: string): string[] => value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
18
|
+
const ignored = new Set(['a','an','the','in','on','at','of','with','and','to']);
|
|
19
|
+
const terms = (value: string) => words(value).filter(word => !ignored.has(word));
|
|
20
|
+
function selectionHint(value: unknown) {
|
|
21
|
+
const phrase = (input: unknown) => {
|
|
22
|
+
if (typeof input !== 'string') return undefined;
|
|
23
|
+
const normalized = input.trim().toLowerCase().replace(/\s+/gu,' ').replaceAll('’', "'");
|
|
24
|
+
return normalized.length <= 48 && /^[\p{L}\p{N} '-]+$/u.test(normalized) && words(normalized).length <= 4 && terms(normalized).length ? normalized : undefined;
|
|
25
|
+
};
|
|
26
|
+
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
|
27
|
+
const subject = phrase(raw.subject);
|
|
28
|
+
if (!subject) return undefined;
|
|
29
|
+
const activity = phrase(raw.activity), equipment = phrase(raw.equipment);
|
|
30
|
+
const exclude = Array.isArray(raw.exclude) && raw.exclude.length <= 3 ? raw.exclude.map(phrase).filter((item): item is string => Boolean(item)) : [];
|
|
31
|
+
return {subject, ...(activity ? {activity} : {}), ...(equipment ? {equipment} : {}), ...(exclude.length ? {exclude} : {})};
|
|
32
|
+
}
|
|
17
33
|
function pexelsUrl(value: unknown): value is string {
|
|
18
34
|
if (typeof value !== "string") return false;
|
|
19
35
|
try {
|
|
@@ -27,11 +43,12 @@ function pexelsUrl(value: unknown): value is string {
|
|
|
27
43
|
* Applications using this adapter must display a prominent link to Pexels.
|
|
28
44
|
* https://www.pexels.com/api/documentation/#guidelines
|
|
29
45
|
*/
|
|
30
|
-
export async function findStockFootage(query: string, orientation: VideoOrientation, signal: AbortSignal) {
|
|
46
|
+
export async function findStockFootage(query: string, orientation: VideoOrientation, signal: AbortSignal, rawSelection?: unknown) {
|
|
31
47
|
signal.throwIfAborted();
|
|
32
48
|
const normalized = query.trim().toLowerCase().replace(/\s+/g, " ");
|
|
33
49
|
const tokens = words(normalized);
|
|
34
|
-
const
|
|
50
|
+
const selection = selectionHint(rawSelection);
|
|
51
|
+
const key = JSON.stringify({version: 2, orientation, query: normalized, selection});
|
|
35
52
|
const apiKey = process.env.PEXELS_API_KEY;
|
|
36
53
|
if (!apiKey || !tokens.length || normalized.length > 80 || tokens.length > 8) return null;
|
|
37
54
|
const existing = cache.get(key);
|
|
@@ -49,8 +66,14 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
|
|
|
49
66
|
const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
|
|
50
67
|
const title = typeof video.title === "string" ? video.title : "";
|
|
51
68
|
const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
|
|
52
|
-
const subject = words(`${slug} ${title} ${tags}`).filter(token => !/^\d+$/.test(token));
|
|
53
|
-
|
|
69
|
+
const subject = words(`${slug} ${title} ${typeof video.description === "string" ? video.description : ""} ${tags}`).filter(token => !/^\d+$/.test(token));
|
|
70
|
+
let matches = tokens.filter(token => subject.includes(token)).length;
|
|
71
|
+
if (selection && subject.length) {
|
|
72
|
+
const covers = (phrase: string) => terms(phrase).every(word => subject.includes(word));
|
|
73
|
+
if (!covers(selection.subject) || selection.exclude?.some(covers)) continue;
|
|
74
|
+
matches = 2 + Number(Boolean(selection.activity && covers(selection.activity)))
|
|
75
|
+
+ Number(Boolean(selection.equipment && covers(selection.equipment)));
|
|
76
|
+
}
|
|
54
77
|
// The documented Video resource can have only a numeric page URL and no
|
|
55
78
|
// editorial metadata. Preserve provider search order for unknown relevance;
|
|
56
79
|
// positive overlap ranks above it, while explicitly unrelated copy is skipped.
|