@vanillaskyai/video 0.10.19 → 0.10.21
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 +11 -0
- package/dist/react.js +382 -181
- package/dist/server.js +37 -5
- package/docs/media-and-audio.md +37 -3
- package/docs/reference/protocol.md +10 -0
- package/package.json +1 -1
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/stock.ts +48 -42
- package/styles/video-chat.css +4 -1
package/dist/server.js
CHANGED
|
@@ -873,6 +873,27 @@ function createVideoHandler(options) {
|
|
|
873
873
|
});
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
// src/server/chat-visual-direction.ts
|
|
877
|
+
var defaults = {
|
|
878
|
+
explanation: "illustrated",
|
|
879
|
+
practical: "realistic",
|
|
880
|
+
story: "cinematic",
|
|
881
|
+
comedy: "cinematic",
|
|
882
|
+
imagination: "cinematic"
|
|
883
|
+
};
|
|
884
|
+
var bibles = {
|
|
885
|
+
illustrated: "Illustrated visual language: clear shaped forms, restrained texture and a coherent limited palette. Use readable spatial relationships, cutaways and purposeful motion to reveal the idea. Keep the same design of subjects and materials across shots.",
|
|
886
|
+
realistic: "Realistic visual language: natural light, credible materials, consistent colour and true physical proportions. Use unobstructed framing and meaningful close views so actions and results are easy to observe. Keep subjects, equipment and setting consistent.",
|
|
887
|
+
cinematic: "Cinematic visual language: intentional lighting, coherent colour and tactile detail. Use purposeful changes of shot scale and viewpoint, with clear action, consequence and a readable final frame. Preserve character appearance and the established world across cuts."
|
|
888
|
+
};
|
|
889
|
+
function compileVisualDirection(brief, callerLook) {
|
|
890
|
+
const intent = typeof brief.intent === "string" && Object.hasOwn(defaults, brief.intent) ? brief.intent : "explanation";
|
|
891
|
+
const visualStyle = typeof brief.visualStyle === "string" && Object.hasOwn(bibles, brief.visualStyle) ? brief.visualStyle : defaults[intent];
|
|
892
|
+
const visualDirection = typeof brief.visualDirection === "string" && brief.visualDirection.trim().length <= 600 ? brief.visualDirection.trim() : "";
|
|
893
|
+
const explicit = typeof callerLook === "string" && callerLook.trim().length <= 1e3 ? callerLook.trim() : "";
|
|
894
|
+
return { intent, visualStyle, visualDirection, generatedLook: explicit || bibles[visualStyle] };
|
|
895
|
+
}
|
|
896
|
+
|
|
876
897
|
// src/server/opening-continuity.ts
|
|
877
898
|
function continueAfterOpening(narration, earlier) {
|
|
878
899
|
const words = (value) => Array.from(value.matchAll(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu));
|
|
@@ -960,6 +981,7 @@ function recoverFirstBrief(part, clipDurationSec) {
|
|
|
960
981
|
if (!ending || !bounded(ending.narration, 2e3) || !bounded(ending.subject, 80, true) || !bounded(ending.action, 600, true) || Object.hasOwn(ending, "title") && !bounded(ending.title, 65) || typeof ending.durationSec !== "number" || !Number.isFinite(ending.durationSec) || ending.continuity !== "cut" && ending.continuity !== "continue") return;
|
|
961
982
|
const subject = text(part.subject, 80);
|
|
962
983
|
return {
|
|
984
|
+
...compileVisualDirection(part),
|
|
963
985
|
opening: text(part.opening, 300),
|
|
964
986
|
subject,
|
|
965
987
|
development: text(part.development, 2e3),
|
|
@@ -978,12 +1000,14 @@ function replaceStream(source, textStream) {
|
|
|
978
1000
|
function createChatShotPlanner(options) {
|
|
979
1001
|
const clipDurationSec = options.generatedClipDurationSec ?? 5;
|
|
980
1002
|
const incomplete = /* @__PURE__ */ new WeakSet();
|
|
1003
|
+
const generatedLooks = /* @__PURE__ */ new WeakMap();
|
|
981
1004
|
const planner = createTextDeltaVideoPlanner({
|
|
982
1005
|
includeRawProviderData: options.includeRawProviderData,
|
|
983
1006
|
streamText(context) {
|
|
984
1007
|
const providerContext = { ...context, userPrompt: [
|
|
985
1008
|
`Create a complete answer within ${context.request.input.maxDurationSec ?? 40} seconds. Each generated clip has at most ${clipDurationSec} seconds; give each spoken beat room to finish.`,
|
|
986
1009
|
`Orientation: ${context.request.input.orientation ?? "landscape"}.`,
|
|
1010
|
+
...context.request.input.style?.generatedLook ? [`CALLER VISUAL DIRECTION (takes precedence over automatic style): ${context.request.input.style.generatedLook}`, "Preserve this requested visual language. The brief visualDirection must contain compatible subjects, setting and palette, never a contradictory rendering style."] : [],
|
|
987
1011
|
"USER REQUEST AND CONVERSATION",
|
|
988
1012
|
context.request.input.input
|
|
989
1013
|
].join("\n") };
|
|
@@ -1005,6 +1029,10 @@ function createChatShotPlanner(options) {
|
|
|
1005
1029
|
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1006
1030
|
if (!getGenerationLifecycleSink(context)?.rejectPart?.(error)) throw error;
|
|
1007
1031
|
};
|
|
1032
|
+
const acceptDirection = (value) => {
|
|
1033
|
+
const direction = compileVisualDirection(value, context.request.input.style?.generatedLook);
|
|
1034
|
+
generatedLooks.set(context, direction.generatedLook);
|
|
1035
|
+
};
|
|
1008
1036
|
const scenePart = (shot, closer = false) => {
|
|
1009
1037
|
let narration = shot.narration;
|
|
1010
1038
|
if (firstBody && !closer) narration = continueAfterOpening(narration, [options.openingLine ?? brief?.opening ?? ""]);
|
|
@@ -1032,12 +1060,14 @@ function createChatShotPlanner(options) {
|
|
|
1032
1060
|
const recovered = firstRecord && !brief && index === 0 ? recoverFirstBrief(part, clipDurationSec) : void 0;
|
|
1033
1061
|
if (recovered) {
|
|
1034
1062
|
brief = recovered;
|
|
1063
|
+
acceptDirection(brief);
|
|
1035
1064
|
options.publishOpening({ line: brief.opening, keyword: brief.subject });
|
|
1036
1065
|
return;
|
|
1037
1066
|
}
|
|
1038
1067
|
if (part?.type === "answer") {
|
|
1039
1068
|
if (brief) throw new Error("Chat answer brief was emitted more than once");
|
|
1040
|
-
brief = { opening: text(part.opening, 300), subject: text(part.subject, 80), visualDirection: text(part.visualDirection, 600), development: text(part.development, 2e3) };
|
|
1069
|
+
brief = { ...compileVisualDirection(part), opening: text(part.opening, 300), subject: text(part.subject, 80), visualDirection: text(part.visualDirection, 600), development: text(part.development, 2e3) };
|
|
1070
|
+
acceptDirection(brief);
|
|
1041
1071
|
if (part.ending) {
|
|
1042
1072
|
try {
|
|
1043
1073
|
brief.ending = readShot(part.ending, clipDurationSec, brief.subject);
|
|
@@ -1144,7 +1174,7 @@ function createChatShotPlanner(options) {
|
|
|
1144
1174
|
});
|
|
1145
1175
|
return async function* (context) {
|
|
1146
1176
|
let completed = false;
|
|
1147
|
-
for await (const part of resolveShots(planner(context), context, options)) {
|
|
1177
|
+
for await (const part of resolveShots(planner(context), context, options, () => options.mode === "pexels" ? context.request.input.style?.generatedLook : generatedLooks.get(context))) {
|
|
1148
1178
|
if (part.type === "plan.complete") completed = true;
|
|
1149
1179
|
yield part;
|
|
1150
1180
|
}
|
|
@@ -1154,7 +1184,7 @@ function createChatShotPlanner(options) {
|
|
|
1154
1184
|
}
|
|
1155
1185
|
};
|
|
1156
1186
|
}
|
|
1157
|
-
async function* resolveShots(parts, context, options) {
|
|
1187
|
+
async function* resolveShots(parts, context, options, generatedLook) {
|
|
1158
1188
|
const queue = [];
|
|
1159
1189
|
const iterator = parts[Symbol.asyncIterator]();
|
|
1160
1190
|
const limit = Number.isFinite(options.mediaConcurrency) ? Math.min(5, Math.max(1, Math.floor(options.mediaConcurrency))) : 1;
|
|
@@ -1171,7 +1201,7 @@ async function* resolveShots(parts, context, options) {
|
|
|
1171
1201
|
scene: part.scene,
|
|
1172
1202
|
templateId: "cinemaMedia",
|
|
1173
1203
|
preferredType: "video",
|
|
1174
|
-
generatedLook: context.request.input.style?.generatedLook,
|
|
1204
|
+
generatedLook: generatedLook() ?? context.request.input.style?.generatedLook,
|
|
1175
1205
|
signal: context.signal
|
|
1176
1206
|
});
|
|
1177
1207
|
context.signal.throwIfAborted();
|
|
@@ -1229,7 +1259,7 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
|
|
|
1229
1259
|
return [
|
|
1230
1260
|
'Use the exact record type "answer" for the first brief and "shot" for developing beats. Output JSON records only, with no prose outside them, including when explaining a limitation.',
|
|
1231
1261
|
"Write a complete, intentful video answer as newline-delimited JSON. Match the user's form and tone; mixed intents can combine directions.",
|
|
1232
|
-
`First write one brief: {"type":"answer","intent":"explanation|story|comedy|imagination|practical","opening":"one useful spoken line of 4\u20137 ordinary 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"}}.`,
|
|
1262
|
+
`First write one brief: {"type":"answer","intent":"explanation|story|comedy|imagination|practical","visualStyle":"illustrated|realistic|cinematic","opening":"one useful spoken line of 4\u20137 ordinary 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"}}.`,
|
|
1233
1263
|
"The opening should take roughly 2\u20133 seconds at a natural pace: give the core answer, a useful starting cue, or the story's immediate situation. No greeting, topic announcement, promise to explain, or description of loading. Never compress away an essential qualifier just to hit the word target.",
|
|
1234
1264
|
`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"}.`,
|
|
1235
1265
|
`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. Never truncate already-authored narration when footage fails. The host selects providers; do not make source choices.`,
|
|
@@ -1237,6 +1267,8 @@ function createVideoChatResponseInstructions(generatedVideoAvailable, openingAlr
|
|
|
1237
1267
|
...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."] : [],
|
|
1238
1268
|
...mode === "pexels" ? ["Choose a separate stock subject for each beat, describing footage that can realistically exist in a stock library. For historical, abstract or unseen events, use relevant present-day evidence, objects, environments or analogous visible processes as clearly illustrative support. Do not require literal footage of events or subjects that cannot realistically be filmed. Keep the causal explanation in narration; do not claim illustrative footage records the historical event or proves the mechanism. Make stockSelection describe the chosen visible subject, not the overall topic. Do not use illustrative freedom to replace a required practical action, person, sport or distinguishing equipment with unrelated scenery."] : [],
|
|
1239
1269
|
...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.'] : [],
|
|
1270
|
+
"Choose one of the five intents and one visualStyle in the first brief. Default explanation to illustrated, practical to realistic, and story, comedy or imagination to cinematic. An explicit visual-style request can choose any of the three. Put its specific medium, palette, character appearance and setting in visualDirection; keep those details consistent through the ending. A supplied caller visual direction takes precedence over these defaults and must not be contradicted.",
|
|
1271
|
+
"The visualStyle names describe generated footage only. Stock mode selects existing literal footage; it cannot redraw or restyle that footage.",
|
|
1240
1272
|
"Keep development to one concise sentence and visualDirection to the few details needed for consistency. Emit the complete brief, then the first developing shot immediately when developing shots are needed and allowed by the budget; otherwise end after the brief. Continue the same stream without an outline, recap or second planning pass. The saved ending must still contain the complete payoff before the brief is emitted.",
|
|
1241
1273
|
"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.",
|
|
1242
1274
|
"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.",
|
package/docs/media-and-audio.md
CHANGED
|
@@ -19,8 +19,8 @@ The chapter retains narration and subtitles for the whole beat.
|
|
|
19
19
|
|
|
20
20
|
The starter performs bounded full-catalog Pexels video search with subject
|
|
21
21
|
matching, orientation-aware renditions and a bounded cache. Add `PEXELS_API_KEY`
|
|
22
|
-
on the server and choose Pexels in Settings. The
|
|
23
|
-
|
|
22
|
+
on the server and choose Pexels in Settings. The default header has no Pexels
|
|
23
|
+
link; the application owns any attribution required by its media provider.
|
|
24
24
|
|
|
25
25
|
Applications can replace `searchMedia` with their own licensed catalog:
|
|
26
26
|
|
|
@@ -50,9 +50,43 @@ default chat displays an authored chapter while retaining the spoken answer. An
|
|
|
50
50
|
validation, and fallback contracts.
|
|
51
51
|
|
|
52
52
|
For Pexels, keep `PEXELS_API_KEY` on the server, enforce a deadline, filter for
|
|
53
|
-
|
|
53
|
+
suitable renditions, and return only validated Pexels asset domains. Licensing,
|
|
54
54
|
attribution, caching, MIME checks, and byte limits remain application-owned.
|
|
55
55
|
|
|
56
|
+
## Automatic visual direction
|
|
57
|
+
|
|
58
|
+
Default chat uses the existing answer brief to choose the requested form:
|
|
59
|
+
explanation (including comparisons), practical instruction, story, comedy, or
|
|
60
|
+
imagination. These shape the content and pacing; they do not change knowledge
|
|
61
|
+
rules or provider allowances.
|
|
62
|
+
|
|
63
|
+
The same brief selects one of three generated-video treatments:
|
|
64
|
+
|
|
65
|
+
- **Illustrated:** clear drawn forms, consistent materials and a restrained palette;
|
|
66
|
+
the default for explanations.
|
|
67
|
+
- **Realistic:** believable lighting, proportions and movement with useful framing;
|
|
68
|
+
the default for practical instruction.
|
|
69
|
+
- **Cinematic:** deliberate composition, lighting and motivated camera movement;
|
|
70
|
+
the default for stories, comedy and imagined worlds.
|
|
71
|
+
|
|
72
|
+
An explicit style request in the prompt takes priority over the default. The
|
|
73
|
+
planner carries its response-specific subjects, palette and setting in the
|
|
74
|
+
brief's visual direction. Every body shot and ending receives the same selected
|
|
75
|
+
base treatment through `generatedLook`, alongside its individual `shotDirection`.
|
|
76
|
+
Adapters must pass both to their video provider. An explicit
|
|
77
|
+
`style.generatedLook` replaces the automatic base treatment.
|
|
78
|
+
|
|
79
|
+
There is no separate classification call or image-generation stage. Selecting a
|
|
80
|
+
look does not guarantee that independently generated clips preserve character
|
|
81
|
+
identity. Evaluate actual footage for subject consistency, useful action,
|
|
82
|
+
narration fit and completion; mocked responses only verify the integration.
|
|
83
|
+
|
|
84
|
+
Pexels retains literal footage queries; these instructions cannot restyle stock
|
|
85
|
+
assets. The opening chapter keeps its existing appearance. Automatic direction
|
|
86
|
+
is generation-time guidance, not a new persisted style field; saved media keeps
|
|
87
|
+
its rendered appearance and existing caller-supplied style persistence is
|
|
88
|
+
unchanged. Custom template planning keeps its existing behavior.
|
|
89
|
+
|
|
56
90
|
## Generated shots
|
|
57
91
|
|
|
58
92
|
Add `generateVideo` to enable generated shots within cinematic responses. It receives the planned visual
|
|
@@ -116,3 +116,13 @@ Grouped playback requires prepared speech with `supportsOffsets: true`. The gene
|
|
|
116
116
|
For standalone playback, provide a synchronous `narrationReady` callback alongside your `onSceneChange` narration handler. Return false while a new grouped paragraph awaits actual audio onset, then true from the voice's `onStart` callback; also release readiness on completion, failure, or interruption. Abort pending narration from the player's `onError` handler. `VideoChat` wires this automatically through its internal narration hook. For grouped paragraphs, the voice must invoke `onStart` when audio actually begins, not when audio is prepared or `play()` is requested. The first visual cue starts narration, then the playhead waits for that onset without pausing the voice. A missing onset stops the player with an error after eight seconds of active waiting. For prepared audio, also provide `narrationTime(scene)` using the active voice's optional `getCurrentTime()`: return paragraph-relative seconds (including the seek offset) for a group, or scene-relative seconds otherwise. This makes the actual audio clock authoritative through cold-start delays and mid-speech stalls. Return `undefined` for silent scenes or unavailable clocks; after ordinary narration completes, release to wall time so the authored reading hold can finish. `VideoChat` coordinates these callbacks automatically. A clock that stops advancing for eight active seconds produces an error; visual-readiness holds and deliberate pauses do not consume that timeout. Voices without an observable playback clock retain wall-time playback.
|
|
117
117
|
|
|
118
118
|
Readiness holds pause narration together with the picture. The same audio continues across adjacent group scenes; replay starts a new playback session. This preserves words through delayed media rather than promising uninterrupted playback on every network.
|
|
119
|
+
|
|
120
|
+
### Host-resolved chat footage mode
|
|
121
|
+
|
|
122
|
+
A chat host that changes the requested footage mode before planning may return
|
|
123
|
+
`x-vanillasky-resolved-video-mode: pexels` or `cinematic` on a successful SSE
|
|
124
|
+
response. The default chat client records that mode for the active turn and its
|
|
125
|
+
playback metrics. Unknown values are ignored. Authorization, allowance checks
|
|
126
|
+
and provider selection remain host-owned; the header never grants access or
|
|
127
|
+
changes a spending limit. A mixed response that changes footage source partway
|
|
128
|
+
through retains its initially resolved mode.
|
package/package.json
CHANGED
|
@@ -53,53 +53,59 @@ export async function findStockFootage(query: string, orientation: VideoOrientat
|
|
|
53
53
|
const normalized = query.trim().toLowerCase().replace(/\s+/g, " ");
|
|
54
54
|
const tokens = words(normalized);
|
|
55
55
|
const selection = selectionHint(rawSelection);
|
|
56
|
-
const key = JSON.stringify({version:
|
|
56
|
+
const key = JSON.stringify({version: 5, orientation, query: normalized, selection});
|
|
57
57
|
const apiKey = process.env.PEXELS_API_KEY;
|
|
58
58
|
if (!apiKey || !tokens.length || normalized.length > 80 || tokens.length > 8) return null;
|
|
59
59
|
const existing = cache.get(key);
|
|
60
60
|
if (existing && existing.expires > Date.now()) return existing.media;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
61
|
+
let selected: StockVideo | null = null;
|
|
62
|
+
// Reuse the caller's deadline signal; broadening never starts a new timeout.
|
|
63
|
+
const queries = [...new Set([normalized, ...(selection ? [selection.subject] : [])])];
|
|
64
|
+
for (const searchQuery of queries) {
|
|
65
|
+
signal.throwIfAborted();
|
|
66
|
+
const url = new URL("https://api.pexels.com/v1/videos/search");
|
|
67
|
+
url.search = new URLSearchParams({ query: searchQuery, per_page: "12", size: "medium" }).toString();
|
|
68
|
+
const response = await fetch(url, { headers: { Authorization: apiKey }, signal });
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
if (!response.ok) return null;
|
|
71
|
+
const result = await response.json() as { videos?: PexelsVideo[] };
|
|
72
|
+
signal.throwIfAborted();
|
|
73
|
+
let bestScore = -1, bestOrientation = -1;
|
|
74
|
+
const matchesOrientation = (file: {width?: number; height?: number}) => orientation === "portrait"
|
|
75
|
+
? file.height! > file.width! : file.width! >= file.height!;
|
|
76
|
+
for (const video of (Array.isArray(result.videos) ? result.videos : []).slice(0, 12)) {
|
|
77
|
+
if (!pexelsUrl(video.url)) continue;
|
|
78
|
+
const slug = new URL(video.url).pathname.replace(/^\/video\//, "");
|
|
79
|
+
const title = typeof video.title === "string" ? video.title : "";
|
|
80
|
+
const tags = Array.isArray(video.tags) ? video.tags.filter((tag): tag is string => typeof tag === "string").join(" ") : "";
|
|
81
|
+
const subject = words(`${slug} ${title} ${typeof video.description === "string" ? video.description : ""} ${tags}`).filter(token => !/^\d+$/.test(token)).map(wordForm);
|
|
82
|
+
let matches = tokens.filter(token => subject.includes(wordForm(token))).length;
|
|
83
|
+
if (selection && subject.length) {
|
|
84
|
+
const covers = (phrase: string) => terms(phrase).every(word => subject.includes(wordForm(word)));
|
|
85
|
+
if (selection.exclude?.some(covers)) continue;
|
|
86
|
+
// Query context breaks equal hint matches without outweighing a hint.
|
|
87
|
+
const contextScore = matches / (tokens.length + 1);
|
|
88
|
+
matches = covers(selection.subject) ? 2 + contextScore + Number(Boolean(selection.activity && covers(selection.activity)))
|
|
89
|
+
+ Number(Boolean(selection.equipment && covers(selection.equipment))) : 0;
|
|
90
|
+
}
|
|
91
|
+
// The documented Video resource can have only a numeric page URL and no
|
|
92
|
+
// editorial metadata. Preserve provider search order for unknown relevance;
|
|
93
|
+
// positive overlap ranks above provider-ranked illustrative alternatives.
|
|
94
|
+
const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
|
|
95
|
+
file.file_type === "video/mp4" && pexelsUrl(file.link)
|
|
96
|
+
&& Number.isFinite(file.width) && Number.isFinite(file.height)
|
|
97
|
+
&& Math.min(file.width!, file.height!) >= 360,
|
|
98
|
+
).sort((a, b) => Number(matchesOrientation(b)) - Number(matchesOrientation(a)) || Math.abs(Math.max(a.width!, a.height!) - 1280) - Math.abs(Math.max(b.width!, b.height!) - 1280));
|
|
99
|
+
const file = files[0];
|
|
100
|
+
if (!file) continue;
|
|
101
|
+
const orientationScore = Number(matchesOrientation(file));
|
|
102
|
+
// Prefer composition fit only when subject relevance is equal.
|
|
103
|
+
if (matches < bestScore || (matches === bestScore && orientationScore <= bestOrientation)) continue;
|
|
104
|
+
bestScore = matches;
|
|
105
|
+
bestOrientation = orientationScore;
|
|
106
|
+
selected = { url: file.link!, type: "video", ...(pexelsUrl(video.image) ? { posterUrl: video.image } : {}) };
|
|
85
107
|
}
|
|
86
|
-
|
|
87
|
-
// editorial metadata. Preserve provider search order for unknown relevance;
|
|
88
|
-
// positive overlap ranks above it, while explicitly unrelated copy is skipped.
|
|
89
|
-
if (subject.length > 0 && matches === 0) continue;
|
|
90
|
-
const files = (Array.isArray(video.video_files) ? video.video_files : []).filter(file =>
|
|
91
|
-
file.file_type === "video/mp4" && pexelsUrl(file.link)
|
|
92
|
-
&& Number.isFinite(file.width) && Number.isFinite(file.height)
|
|
93
|
-
&& Math.min(file.width!, file.height!) >= 360,
|
|
94
|
-
).sort((a, b) => Number(matchesOrientation(b)) - Number(matchesOrientation(a)) || Math.abs(Math.max(a.width!, a.height!) - 1280) - Math.abs(Math.max(b.width!, b.height!) - 1280));
|
|
95
|
-
const file = files[0];
|
|
96
|
-
if (!file) continue;
|
|
97
|
-
const orientationScore = Number(matchesOrientation(file));
|
|
98
|
-
// Prefer composition fit only when subject relevance is equal.
|
|
99
|
-
if (matches < bestScore || (matches === bestScore && orientationScore <= bestOrientation)) continue;
|
|
100
|
-
bestScore = matches;
|
|
101
|
-
bestOrientation = orientationScore;
|
|
102
|
-
selected = { url: file.link!, type: "video", ...(pexelsUrl(video.image) ? { posterUrl: video.image } : {}) };
|
|
108
|
+
if (selected) break;
|
|
103
109
|
}
|
|
104
110
|
// Bounded process-local cache; no request signal or credentials are retained.
|
|
105
111
|
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
|
package/styles/video-chat.css
CHANGED
|
@@ -1065,7 +1065,6 @@
|
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
1067
|
.vanillasky-video-chat .opening-chapter { animation: vanillasky-chapter-enter 450ms ease-out both; }
|
|
1068
|
-
.vanillasky-video-chat .media-credit { color: var(--vs-fg); font-size: 11px; margin-left: 12px; text-underline-offset: 3px; }
|
|
1069
1068
|
@keyframes vanillasky-chapter-enter { from { opacity: 0; } to { opacity: 1; } }
|
|
1070
1069
|
@media (prefers-reduced-motion: reduce) { .vanillasky-video-chat .opening-chapter { animation: none; } }
|
|
1071
1070
|
|
|
@@ -1124,3 +1123,7 @@
|
|
|
1124
1123
|
.vanillasky-video-chat .card-prompt { position: relative; inset: auto; overflow-wrap: anywhere; flex-shrink: 0; }
|
|
1125
1124
|
|
|
1126
1125
|
.vanillasky-video-chat .cards { flex-shrink: 0; }
|
|
1126
|
+
|
|
1127
|
+
.vanillasky-video-chat .transcript-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: var(--vs-media-glass); color: var(--vs-media-muted); font: inherit; font-size: 13px; cursor: pointer; pointer-events: auto; }
|
|
1128
|
+
.vanillasky-video-chat .transcript-toggle svg { width: 16px; height: 16px; }
|
|
1129
|
+
.vanillasky-video-chat .transcript-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|