@vanillaskyai/video 0.7.1 → 0.8.0

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/PUBLIC-API.md +83 -79
  3. package/README.md +16 -15
  4. package/dist/{builtin-server-KB7FKF6A.js → builtin-server-CUYPIDE7.js} +2 -2
  5. package/dist/check-runtime.js +1 -1
  6. package/dist/{chunk-2PYO6VAC.js → chunk-2DOLF4QN.js} +1 -43
  7. package/dist/{chunk-GAIOHGNR.js → chunk-2OKB27TG.js} +42 -45
  8. package/dist/{chunk-K746NUIP.js → chunk-2USVB3CY.js} +80 -29
  9. package/dist/{chunk-RE4IMWJR.js → chunk-3VRMKFM7.js} +87 -14
  10. package/dist/{chunk-JNN3EYVP.js → chunk-4UNKZEAT.js} +46 -46
  11. package/dist/{chunk-2XT4MZ76.js → chunk-APQKY6P3.js} +36 -3
  12. package/dist/{chunk-LLPMVDJZ.js → chunk-L7QGGKJ2.js} +69 -38
  13. package/dist/{chunk-RGF452LL.js → chunk-OWBT45EN.js} +1 -1
  14. package/dist/{chunk-LKBZX7GV.js → chunk-PP3MCPN4.js} +4 -4
  15. package/dist/chunk-ZD2VTUYR.js +28 -0
  16. package/dist/cli.js +131 -24
  17. package/dist/{compose-video-PD6LKKRK.js → compose-video-2NFH4DZY.js} +3 -3
  18. package/dist/{events-B6qS1Lsb.d.ts → events-BQ6z264n.d.ts} +1 -1
  19. package/dist/index.d.ts +5 -67
  20. package/dist/index.js +2 -4
  21. package/dist/{kit-8g46H2RZ.d.ts → kit-tQnCPMOM.d.ts} +1 -1
  22. package/dist/react.d.ts +33 -53
  23. package/dist/react.js +642 -536
  24. package/dist/server.d.ts +11 -13
  25. package/dist/server.js +170 -81
  26. package/dist/template-catalog.js +1 -1
  27. package/dist/templates.d.ts +3 -3
  28. package/dist/test.d.ts +2 -2
  29. package/dist/test.js +3 -3
  30. package/dist/{text-stream-ROTNGSD4.js → text-stream-54LXABJW.js} +1 -1
  31. package/dist/{types-2wHBqtg8.d.ts → types-CEh9eUFu.d.ts} +1 -1
  32. package/dist/{types-DrABlRa7.d.ts → types-DWFO6qca.d.ts} +1 -1
  33. package/docs/agent-integration.md +9 -3
  34. package/docs/architecture.md +8 -10
  35. package/docs/concepts.md +16 -40
  36. package/docs/custom-templates.md +27 -23
  37. package/docs/customization.md +12 -49
  38. package/docs/errors.md +58 -68
  39. package/docs/getting-started.md +21 -14
  40. package/docs/media-and-audio.md +2 -2
  41. package/docs/performance.md +69 -0
  42. package/docs/persistence.md +12 -27
  43. package/docs/production.md +2 -2
  44. package/docs/provider-integration.md +11 -5
  45. package/docs/reference/provider-adapters.md +37 -89
  46. package/docs/security.md +1 -1
  47. package/docs/streaming-protocol.md +2 -2
  48. package/examples/custom-template/README.md +6 -6
  49. package/package.json +6 -7
  50. package/registry/items/barChart.json +2 -2
  51. package/registry/items/bigNumber.json +2 -2
  52. package/registry/items/brandMessage.json +2 -2
  53. package/registry/items/cardList.json +2 -2
  54. package/registry/items/codeEditor.json +2 -2
  55. package/registry/items/ctaLogo.json +2 -2
  56. package/registry/items/ctaMedia.json +2 -2
  57. package/registry/items/incomingCall.json +2 -2
  58. package/registry/items/media.json +2 -2
  59. package/registry/items/milestone.json +2 -2
  60. package/registry/items/notification.json +2 -2
  61. package/registry/items/phoneMockup.json +2 -2
  62. package/registry/items/problemSolution.json +2 -2
  63. package/registry/items/progressRing.json +2 -2
  64. package/registry/items/promptInput.json +2 -2
  65. package/registry/items/reaction.json +2 -2
  66. package/registry/items/reviewStack.json +2 -2
  67. package/registry/items/steps.json +2 -2
  68. package/registry/items/terminal.json +2 -2
  69. package/registry/items/testimonial.json +2 -2
  70. package/registry/items/tripleStats.json +2 -2
  71. package/registry/items/tweet.json +2 -2
  72. package/registry/items/webMockup.json +2 -2
  73. package/starters/video-chat/.env.example +4 -2
  74. package/starters/video-chat/README.md +17 -5
  75. package/starters/video-chat/package.json +2 -4
  76. package/starters/video-chat/providers/speech.ts +20 -0
  77. package/starters/video-chat/providers/video.ts +66 -0
  78. package/starters/video-chat/providers.ts +3 -0
  79. package/starters/video-chat/server.ts +4 -80
  80. package/starters/video-chat/stock.ts +114 -99
  81. package/dist/chunk-SKRGRKHY.js +0 -142
  82. package/dist/state-DZcKuS32.d.ts +0 -3
  83. package/docs/branding-and-personalization.md +0 -87
  84. package/examples/custom-template/supplied-media.tsx +0 -92
@@ -1,26 +1,10 @@
1
- /**
2
- * Stock footage for the modes that do not generate any.
3
- *
4
- * A rendered card on a brand gradient is the least a template can look like,
5
- * and templates-only mode was every scene that way. A stock search costs a few
6
- * hundred milliseconds and nothing per request, so unlike a generated clip it
7
- * never lands on the critical path - the difference between cards on gradients
8
- * and cards over real footage is close to free.
9
- *
10
- * Ported from the site's playground, trimmed to what a video chat needs. Every URL
11
- * that comes back is checked against the host it must come from: the planner
12
- * asks for a subject, and what a search returns is not something to trust into
13
- * a page unexamined.
14
- */
15
1
  import type { VideoOrientation } from "@vanillaskyai/video";
16
2
 
17
- const VIDEO_SEARCH = "https://api.pexels.com/videos/search";
3
+ const VIDEO_SEARCH = "https://api.pexels.com/v1/videos/search";
18
4
  const PHOTO_SEARCH = "https://api.pexels.com/v1/search";
19
5
  const MAX_KEYWORD_LENGTH = 80;
20
- // Shorter than the shot deadline by two orders of magnitude, deliberately: a
21
- // scene with no footage is a scene on a gradient, and that is a better outcome
22
- // than a response that waits.
23
- const LOOKUP_TIMEOUT_MS = 4_000;
6
+ // One budget covers specific/broader video searches and photo recovery.
7
+ const LOOKUP_TIMEOUT_MS = 3_000;
24
8
 
25
9
  interface StockMedia {
26
10
  url: string;
@@ -28,12 +12,6 @@ interface StockMedia {
28
12
  posterUrl?: string;
29
13
  }
30
14
 
31
- interface VideoFile {
32
- link?: unknown;
33
- width?: number;
34
- height?: number;
35
- }
36
-
37
15
  /** One hour, keyed by query. The same response asked twice searches once. */
38
16
  const cache = new Map<string, { payload: unknown; expiresAt: number }>();
39
17
 
@@ -45,95 +23,132 @@ function normalizeKeyword(value: string): string {
45
23
  .slice(0, MAX_KEYWORD_LENGTH);
46
24
  }
47
25
 
48
- function trustedUrl(value: unknown, host: string): string {
49
- const url = new URL(String(value));
50
- if (url.protocol !== "https:" || url.hostname !== host) {
51
- throw new Error(`Stock search returned a URL outside ${host}`);
26
+ function record(value: unknown): Record<string, unknown> {
27
+ return value !== null && typeof value === "object" && !Array.isArray(value)
28
+ ? value as Record<string, unknown>
29
+ : {};
30
+ }
31
+
32
+ function entries(value: unknown): unknown[] {
33
+ return Array.isArray(value) ? value : [];
34
+ }
35
+
36
+ function trustedUrl(value: unknown, host: string): string | undefined {
37
+ if (typeof value !== "string") return undefined;
38
+ try {
39
+ const url = new URL(value);
40
+ if (url.protocol !== "https:" || url.hostname !== host || url.username || url.password || url.port) return undefined;
41
+ return url.toString();
42
+ } catch {
43
+ return undefined;
52
44
  }
53
- return url.toString();
54
45
  }
55
46
 
56
- function pickVideoFile(files: VideoFile[], orientation: VideoOrientation): VideoFile & { link: string } | null {
57
- const usable = files
58
- .filter((file): file is VideoFile & { link: string } => typeof file.link === "string")
59
- // One file on an unexpected host is one file to skip, not a reason to give
60
- // up the search: throwing here lost every other rendition of the same clip.
61
- .flatMap((file) => {
62
- try {
63
- return [{ ...file, link: trustedUrl(file.link, "videos.pexels.com") }];
64
- } catch {
65
- return [];
66
- }
67
- })
68
- .filter((file) => file.width && file.height);
69
- // Matched to the stage, because a clip cropped to the other shape loses
70
- // whatever the search was actually for - a landscape river in a portrait
71
- // frame is a column of water with both banks cut off.
72
- const wanted = orientation === "portrait"
73
- ? (file: VideoFile) => (file.height ?? 0) >= (file.width ?? 0)
74
- : (file: VideoFile) => (file.width ?? 0) >= (file.height ?? 0);
75
- return usable.find(wanted) ?? usable[0] ?? null;
47
+ function pickVideoFile(files: unknown, orientation: VideoOrientation): string | undefined {
48
+ const usable = entries(files).flatMap((value) => {
49
+ const file = record(value);
50
+ const direct = trustedUrl(file.link, "videos.pexels.com");
51
+ const external = trustedUrl(file.link, "player.vimeo.com");
52
+ const link = direct ?? (external && /^\/external\/[a-zA-Z0-9._-]+\.mp4$/.test(new URL(external).pathname) ? external : undefined);
53
+ const { width, height } = file;
54
+ if (!link || typeof width !== "number" || typeof height !== "number"
55
+ || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return [];
56
+ return [{ link, width, height }];
57
+ });
58
+ // Prefer the stage's shape without discarding other safe renditions.
59
+ return (usable.find((file) => orientation === "portrait"
60
+ ? file.height >= file.width
61
+ : file.width >= file.height) ?? usable[0])?.link;
76
62
  }
77
63
 
78
64
  async function search(url: string, apiKey: string, signal: AbortSignal): Promise<unknown> {
79
65
  const cached = cache.get(url);
80
66
  if (cached && cached.expiresAt > Date.now()) return cached.payload;
81
67
 
82
- const response = await fetch(url, {
83
- headers: { Authorization: apiKey },
84
- signal: AbortSignal.any([signal, AbortSignal.timeout(LOOKUP_TIMEOUT_MS)]),
85
- });
86
- // Only a real response is worth keeping. Caching the failure would let one
87
- // rate-limited minute leave every response about the Moon on a gradient for
88
- // the hour that followed.
89
- if (!response.ok) return null;
90
- const payload = await response.json();
91
- cache.set(url, { payload, expiresAt: Date.now() + 60 * 60 * 1_000 });
92
- if (cache.size > 100) cache.delete(cache.keys().next().value!);
93
- return payload;
68
+ if (signal.aborted) return null;
69
+ try {
70
+ const response = await fetch(url, {
71
+ headers: { Authorization: apiKey },
72
+ signal,
73
+ });
74
+ if (!response.ok || signal.aborted) return null;
75
+ const payload: unknown = await response.json();
76
+ if (signal.aborted) return null;
77
+ cache.set(url, { payload, expiresAt: Date.now() + 60 * 60 * 1_000 });
78
+ if (cache.size > 100) cache.delete(cache.keys().next().value!);
79
+ return payload;
80
+ } catch {
81
+ // A failed video search must still allow the independent photo fallback.
82
+ if (!signal.aborted) console.warn("[video-chat] Stock lookup unavailable; trying a fallback.");
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /** Metadata is a weak mismatch filter, never visual verification. Numeric URLs
88
+ * and missing descriptions are unknown and retain the provider's search ranking. */
89
+ function matchesMetadata(item: Record<string, unknown>, keyword: string): boolean {
90
+ let description = typeof item.alt === "string" ? item.alt : "";
91
+ if (!description) {
92
+ const page = trustedUrl(item.url, "www.pexels.com");
93
+ if (page) description = new URL(page).pathname.replace(/^\/(?:video|photo)\//, "");
94
+ }
95
+ const words = (value: string) => normalizeKeyword(value).toLowerCase().split(/[\s-]+/)
96
+ .filter(word => /\p{L}/u.test(word) && !["a", "an", "the", "of", "in", "on", "with", "and", "video", "photo", "free", "footage"].includes(word));
97
+ const labels = words(description);
98
+ const requested = words(keyword);
99
+ return labels.length === 0 || requested.length === 0 || requested.some(word => labels.includes(word));
94
100
  }
95
101
 
96
- /**
97
- * Find footage for one beat. Video first, a photograph if there is none.
98
- *
99
- * Returns null rather than throwing when there is nothing to show: the scene
100
- * falls back to the brand gradient, which is what it looked like before.
101
- */
102
- export async function findStockFootage(subject: string, orientation: VideoOrientation, signal: AbortSignal): Promise<StockMedia | null> {
102
+ /** Video queries precede photos. All work shares one deadline, including body
103
+ * parsing, even if a custom fetch implementation ignores its abort signal. */
104
+ export async function findStockFootage(subject: string, orientation: VideoOrientation, signal: AbortSignal, fallbackSubject?: string): Promise<StockMedia | null> {
103
105
  const apiKey = process.env.PEXELS_API_KEY;
104
106
  const keyword = normalizeKeyword(subject);
105
- if (!apiKey || !keyword) return null;
106
-
107
- const params = new URLSearchParams({ query: keyword, orientation, per_page: "3", page: "1" });
108
- const started = Date.now();
109
- try {
110
- const videos = await search(`${VIDEO_SEARCH}?${params}`, apiKey, signal) as
111
- { videos?: Array<{ image?: unknown; video_files?: VideoFile[] }> } | null;
112
- for (const video of videos?.videos ?? []) {
113
- const file = pickVideoFile(video.video_files ?? [], orientation);
114
- if (!file) continue;
115
- console.log(`[video-chat] stock video in ${Date.now() - started}ms: ${keyword}`);
116
- return {
117
- url: file.link,
118
- type: "video",
119
- // The still shown while the video decodes its first frame, which is
120
- // otherwise a flash of gradient where the footage should be.
121
- ...(video.image ? { posterUrl: trustedUrl(video.image, "images.pexels.com") } : {}),
122
- };
107
+ if (!apiKey || !keyword || signal.aborted) return null;
108
+ const keywords = [keyword];
109
+ const fallback = normalizeKeyword(fallbackSubject ?? "");
110
+ if (fallback && fallback.toLowerCase() !== keyword.toLowerCase()) keywords.push(fallback);
111
+ const controller = new AbortController();
112
+ let finish!: (value: null) => void;
113
+ const stopped = new Promise<null>(resolve => { finish = resolve; });
114
+ const abort = () => { controller.abort(); finish(null); };
115
+ signal.addEventListener("abort", abort, { once: true });
116
+ const timer = setTimeout(abort, LOOKUP_TIMEOUT_MS);
117
+ const active = controller.signal;
118
+ const lookup = async (): Promise<StockMedia | null> => {
119
+ for (const query of keywords) {
120
+ const params = new URLSearchParams({ query, orientation, per_page: "3", page: "1" });
121
+ const videos = record(await search(`${VIDEO_SEARCH}?${params}`, apiKey, active));
122
+ if (active.aborted) return null;
123
+ for (const value of entries(videos.videos)) {
124
+ const video = record(value);
125
+ if (!matchesMetadata(video, query)) continue;
126
+ const url = pickVideoFile(video.video_files, orientation);
127
+ if (!url) continue;
128
+ const posterUrl = trustedUrl(video.image, "images.pexels.com");
129
+ return { url, type: "video", ...(posterUrl ? { posterUrl } : {}) };
130
+ }
123
131
  }
124
-
125
- const photos = await search(`${PHOTO_SEARCH}?${params}`, apiKey, signal) as
126
- { photos?: Array<{ src?: { large2x?: unknown; large?: unknown } }> } | null;
127
- const photo = photos?.photos?.[0]?.src?.large2x ?? photos?.photos?.[0]?.src?.large;
128
- if (!photo) return null;
129
- console.log(`[video-chat] stock photo in ${Date.now() - started}ms: ${keyword}`);
130
- return { url: trustedUrl(photo, "images.pexels.com"), type: "image" };
131
- } catch (cause) {
132
- // A timeout, an abort, or a search that returned something unexpected. The
133
- // beat keeps its copy on the brand gradient.
134
- if (!signal.aborted) {
135
- console.warn(`[video-chat] stock search failed after ${Date.now() - started}ms:`, cause instanceof Error ? cause.message : cause);
132
+ for (const query of keywords) {
133
+ const params = new URLSearchParams({ query, orientation, per_page: "3", page: "1" });
134
+ const photos = record(await search(`${PHOTO_SEARCH}?${params}`, apiKey, active));
135
+ if (active.aborted) return null;
136
+ for (const value of entries(photos.photos)) {
137
+ const photo = record(value);
138
+ if (!matchesMetadata(photo, query)) continue;
139
+ const source = record(photo.src);
140
+ for (const candidate of [source.large2x, source.large]) {
141
+ const url = trustedUrl(candidate, "images.pexels.com");
142
+ if (url) return { url, type: "image" };
143
+ }
144
+ }
136
145
  }
137
146
  return null;
147
+ };
148
+ try {
149
+ return await Promise.race([lookup(), stopped]);
150
+ } finally {
151
+ clearTimeout(timer);
152
+ signal.removeEventListener("abort", abort);
138
153
  }
139
154
  }
@@ -1,142 +0,0 @@
1
- import {
2
- checksumVideo,
3
- createVideoEventFactory
4
- } from "./chunk-2PYO6VAC.js";
5
- import {
6
- VIDEO_SCHEMA_VERSION
7
- } from "./chunk-AOWSXU2K.js";
8
- import {
9
- getReadableSceneDuration
10
- } from "./chunk-3O7OMMMF.js";
11
-
12
- // src/protocol/scene-timeline.ts
13
- function createQueue() {
14
- const buffered = [];
15
- const waiting = [];
16
- let closed = false;
17
- return {
18
- push(value) {
19
- const next = waiting.shift();
20
- if (next) next({ value, done: false });
21
- else buffered.push(value);
22
- },
23
- close() {
24
- closed = true;
25
- let next = waiting.shift();
26
- while (next) {
27
- next({ value: void 0, done: true });
28
- next = waiting.shift();
29
- }
30
- },
31
- iterable: {
32
- [Symbol.asyncIterator]() {
33
- return {
34
- next() {
35
- const buffer = buffered.shift();
36
- if (buffer !== void 0) return Promise.resolve({ value: buffer, done: false });
37
- if (closed) return Promise.resolve({ value: void 0, done: true });
38
- return new Promise((resolve) => waiting.push(resolve));
39
- }
40
- };
41
- }
42
- }
43
- };
44
- }
45
- var runCounter = 0;
46
- function createSceneTimeline(options) {
47
- const queue = createQueue();
48
- const runId = options.runId ?? `composed-${runCounter += 1}`;
49
- const events = createVideoEventFactory({ runId });
50
- const orientation = options.orientation ?? "landscape";
51
- const snapshot = {
52
- schemaVersion: VIDEO_SCHEMA_VERSION,
53
- orientation,
54
- scenes: [],
55
- style: options.style
56
- };
57
- let position = 0;
58
- let finished = false;
59
- const held = [];
60
- let openForScenes = !options.awaitAudio;
61
- const emit = (event) => {
62
- if (finished) return;
63
- queue.push(event);
64
- };
65
- emit(events.create("response.start", {
66
- requestId: options.requestId ?? runId,
67
- format: { orientation },
68
- style: options.style
69
- }));
70
- if (options.audio) {
71
- snapshot.audio = options.audio;
72
- emit(events.create("audio.set", { audio: options.audio }));
73
- }
74
- const release = () => {
75
- const pending = held.splice(0, held.length);
76
- for (const scene of pending) {
77
- snapshot.scenes.push(scene);
78
- emit(events.create("scene.add", { scene, position: position++ }));
79
- }
80
- };
81
- return {
82
- stream: queue.iterable,
83
- add(scene) {
84
- if (finished) return;
85
- if (!openForScenes) {
86
- held.push(scene);
87
- return;
88
- }
89
- snapshot.scenes.push(scene);
90
- emit(events.create("scene.add", { scene, position: position++ }));
91
- },
92
- setAudio(audio) {
93
- if (finished || openForScenes) return;
94
- if (audio) {
95
- snapshot.audio = audio;
96
- emit(events.create("audio.set", { audio }));
97
- }
98
- openForScenes = true;
99
- release();
100
- },
101
- complete() {
102
- if (finished) return;
103
- if (!openForScenes) {
104
- openForScenes = true;
105
- release();
106
- }
107
- emit(events.create("response.complete", {
108
- finishReason: "stop",
109
- snapshot,
110
- checksum: checksumVideo(snapshot)
111
- }));
112
- finished = true;
113
- queue.close();
114
- }
115
- };
116
- }
117
-
118
- // src/protocol/scene-duration.ts
119
- var NARRATION_WORDS_PER_SECOND = 2.6;
120
- var NARRATION_TAIL_SECONDS = 0.6;
121
- function getSceneDurationBounds(scene, metadata) {
122
- return {
123
- minimum: Math.max(1e-3, metadata?.minDuration ?? 1),
124
- readable: getReadableSceneDuration(scene, metadata)
125
- };
126
- }
127
- function getSpokenDuration(narration) {
128
- const words = narration.trim().split(/\s+/u).filter(Boolean).length;
129
- if (words === 0) return 0;
130
- return words / NARRATION_WORDS_PER_SECOND + NARRATION_TAIL_SECONDS;
131
- }
132
- function getSceneDuration(scene, metadata) {
133
- const { readable } = getSceneDurationBounds(scene, metadata);
134
- return Math.max(readable, scene.narration ? getSpokenDuration(scene.narration) : 0);
135
- }
136
-
137
- export {
138
- createSceneTimeline,
139
- getSceneDurationBounds,
140
- getSpokenDuration,
141
- getSceneDuration
142
- };
@@ -1,3 +0,0 @@
1
- type VideoStatus = "idle" | "streaming" | "complete" | "error" | "aborted";
2
-
3
- export type { VideoStatus as V };
@@ -1,87 +0,0 @@
1
- [← Documentation home](../README.md) · [Previous: Prompt and conversation input](prompt-and-input.md) · [Next: Media and audio →](media-and-audio.md)
2
-
3
- # Branding and personalization
4
-
5
- Brand values style the whole response. Personalization shapes who the story is for.
6
-
7
- ## Background: standard, preset, or custom
8
-
9
- Do nothing to use the standard `cosmic` background:
10
-
11
- ```ts
12
- video.generate({ input });
13
- ```
14
-
15
- Choose a curated gradient or solid by name when the standard does not fit:
16
-
17
- ```ts
18
- video.generate({
19
- input,
20
- brand: { background: "twilight" },
21
- });
22
- ```
23
-
24
- Curated gradients are `cosmic`, `horizon`, `twilight`, `meadow`, `velvet`,
25
- `flamingo`, `peach`, and `saffron`. Curated solids are `black`, `midnight`,
26
- `aubergine`, `coal`, and `navy`.
27
-
28
- Use exact colors only when a real brand or campaign requires them:
29
-
30
- ```ts
31
- brand: { background: { colors: ["#112233", "#334455"] } } // gradient
32
- brand: { background: { color: "#070B20" } } // solid
33
- ```
34
-
35
- These choices set the background colors used by every trusted gradient-backed
36
- template and by the automatic generation cover. Templates own their
37
- composition and type treatment, so there is no separate visual-preset choice.
38
-
39
- ## Semantic brand tokens
40
-
41
- Most applications can stop at `brand.background` and `brand.font`. Supply
42
- semantic colors only when a real design system requires exact values:
43
-
44
- ```ts
45
- const brand = {
46
- name: "Acme Cloud",
47
- logoUrl: approvedLogoUrl,
48
- font: "Inter",
49
- scriptFont: "Caveat",
50
- background: "twilight",
51
- colors: {
52
- primary: "#7C5CFC",
53
- secondary: "#3D2A78",
54
- foreground: "#FFFFFF",
55
- surface: "#161229",
56
- surfaceElevated: "#211A38",
57
- muted: "#AAA5B8",
58
- },
59
- };
60
- ```
61
-
62
- Every generated video contains a fully resolved brand. `colors` may be partial
63
- on input; omitted values receive deterministic defaults. When
64
- `colors.foreground` is omitted, every named or custom background is paired
65
- deterministically with black or white. The resolver checks the full rendered
66
- sRGB gradient ramp, including interior blends rather than only its endpoints.
67
- If neither foreground reaches 4.5:1 everywhere, resolution rejects the
68
- background and asks for a consistently readable choice.
69
-
70
- An explicitly supplied foreground is preserved, never silently replaced. It
71
- must reach 4.5:1 across the solid background or entire gradient ramp; otherwise
72
- input resolution and replay validation reject it with the failing brand path
73
- and minimum contrast. Surface tokens remain independent. Templates that put
74
- text on `surface` or `surfaceElevated` internally keep an accessible preferred
75
- semantic text color or derive black/white for that surface.
76
-
77
- Use an approved public or signed URL for logos. Fonts must be licensed and
78
- loaded by the host application before playback. Always test both orientations
79
- and forced browser zoom.
80
-
81
- `personalization` accepts JSON-safe viewer or account context such as
82
- `firstName`, `role`, `accountName`, `locale`, `goal`, or `onboardingPartner`.
83
- These values may appear verbatim in the response, but are always data, never
84
- instructions. Put product, news, metric, and quoted claims in `input`; do not
85
- duplicate ordinary viewer context there.
86
-
87
- Only include information the authenticated viewer is allowed to see. Avoid sensitive HR, health, payment, or customer attributes in event logs and analytics. If a locale changes number/date formatting, format facts server-side so the planner cannot guess.
@@ -1,92 +0,0 @@
1
- import { defineTemplate } from "@vanillaskyai/video/templates";
2
-
3
- const previewImageUrl = "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%221200%22%20height=%22720%22%20viewBox=%220%200%201200%20720%22%3E%3Crect%20width=%221200%22%20height=%22720%22%20rx=%2248%22%20fill=%22%23181733%22/%3E%3Crect%20x=%2280%22%20y=%2280%22%20width=%221040%22%20height=%22560%22%20rx=%2232%22%20fill=%22%23f4f1ff%22/%3E%3Ccircle%20cx=%22220%22%20cy=%22230%22%20r=%2270%22%20fill=%22%237867dd%22/%3E%3Cpath%20d=%22M160%20520L380%20360L580%20430L820%20220L1040%20300%22%20fill=%22none%22%20stroke=%22%23287b77%22%20stroke-width=%2232%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22/%3E%3C/svg%3E";
4
-
5
- export const template = defineTemplate({
6
- id: "supplied-media",
7
- label: "Supplied media",
8
- description: "An application-supplied screenshot with a grounded caption.",
9
- useWhen: "Use when a supplied product image is the strongest evidence for the answer.",
10
- avoidWhen: "Avoid when no approved image was supplied or text and data communicate the point more clearly.",
11
- schema: {
12
- type: "object",
13
- properties: {
14
- imageUrl: {
15
- type: "string",
16
- format: "supplied-image",
17
- title: "Supplied image",
18
- description: "The URL of an image present in VideoInput.suppliedMedia.",
19
- default: previewImageUrl,
20
- },
21
- headline: {
22
- type: "string",
23
- title: "Headline",
24
- minLength: 1,
25
- maxLength: 72,
26
- default: "See the change in context.",
27
- },
28
- caption: {
29
- type: "string",
30
- title: "Caption",
31
- minLength: 1,
32
- maxLength: 120,
33
- default: "The dashboard reflects the grounded result described in the answer.",
34
- },
35
- },
36
- required: ["imageUrl", "headline", "caption"],
37
- additionalProperties: false,
38
- } as const,
39
- examples: [{
40
- name: "Product dashboard",
41
- variables: {
42
- imageUrl: previewImageUrl,
43
- headline: "See the change in context.",
44
- caption: "The dashboard reflects the grounded result described in the answer.",
45
- },
46
- }],
47
- minDuration: 4,
48
- preferredDuration: 6,
49
- component: ({ variables, progress, width, height, safeZone }) => {
50
- const isPortrait = height >= width;
51
- const reveal = Math.max(0, Math.min(1, progress * 1.7));
52
-
53
- return <section style={{
54
- boxSizing: "border-box",
55
- width,
56
- height,
57
- padding: `${safeZone.top}px ${safeZone.right}px ${safeZone.bottom}px ${safeZone.left}px`,
58
- display: "grid",
59
- gridTemplateColumns: isPortrait ? "1fr" : "minmax(0, 1.1fr) minmax(320px, 0.9fr)",
60
- alignContent: "center",
61
- alignItems: "center",
62
- gap: isPortrait ? 44 : 64,
63
- overflow: "hidden",
64
- color: "#15152b",
65
- background: "linear-gradient(145deg, #f4f1ff, #dfe7ff)",
66
- fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
67
- }}>
68
- <img
69
- src={variables.imageUrl}
70
- alt=""
71
- style={{
72
- display: "block",
73
- width: "100%",
74
- maxHeight: isPortrait ? height * 0.48 : height * 0.68,
75
- objectFit: "cover",
76
- borderRadius: 28,
77
- boxShadow: "0 28px 80px rgba(28, 34, 75, 0.24)",
78
- opacity: reveal,
79
- transform: `scale(${0.96 + reveal * 0.04})`,
80
- }}
81
- />
82
- <div style={{ opacity: reveal }}>
83
- <h1 style={{ margin: 0, fontSize: isPortrait ? 58 : 70, lineHeight: 0.98, letterSpacing: "-0.045em" }}>
84
- {variables.headline}
85
- </h1>
86
- <p style={{ margin: "24px 0 0", fontSize: isPortrait ? 25 : 28, lineHeight: 1.4 }}>
87
- {variables.caption}
88
- </p>
89
- </div>
90
- </section>;
91
- },
92
- });