@vanillaskyai/video 0.10.23 → 0.11.1

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 (127) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/PUBLIC-API.md +47 -438
  3. package/README.md +53 -97
  4. package/dist/builtin-metadata-OT6V7TB4.js +8 -0
  5. package/dist/{chapter-title-2JVDU62E.js → chapter-title-2RCXX7SN.js} +1 -2
  6. package/dist/{chunk-5BSJLT6H.js → chunk-2WIETEL7.js} +16 -29
  7. package/dist/{chunk-3O7OMMMF.js → chunk-35K6IKB2.js} +3 -3
  8. package/dist/{chunk-PDQFQIQW.js → chunk-3EQ6PVWL.js} +1 -1
  9. package/dist/chunk-4G4JBMCM.js +37 -0
  10. package/dist/{chunk-7AA2JWHZ.js → chunk-5DOQTIMD.js} +6 -6
  11. package/dist/chunk-666HGTVZ.js +41 -0
  12. package/dist/{chunk-7M56IUUX.js → chunk-6Z3ID54H.js} +0 -18
  13. package/dist/chunk-AUN4S3YW.js +50 -0
  14. package/dist/chunk-HIQM4J3U.js +30 -0
  15. package/dist/chunk-ISWQ6T5X.js +36 -0
  16. package/dist/{chunk-224QNWRA.js → chunk-K5J7ESRO.js} +1 -10
  17. package/dist/chunk-NM4CXXZY.js +25 -0
  18. package/dist/{chunk-44WND2VP.js → chunk-RFXHLLYU.js} +34 -62
  19. package/dist/{chunk-RXTN2CW6.js → chunk-Z3DLSLAJ.js} +1 -1
  20. package/dist/cinema-media-YZ2UANJG.js +365 -0
  21. package/dist/cli.js +154 -1748
  22. package/dist/{compose-video-BH5K6XWT.js → compose-video-RSLI4CVP.js} +4 -4
  23. package/dist/{events-B4YCc4vc.d.ts → events-BZAfl0Dm.d.ts} +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +4 -6
  26. package/dist/preload-media-23JXUFCF.js +77 -0
  27. package/dist/react.d.ts +39 -15
  28. package/dist/react.js +1355 -740
  29. package/dist/scene-validation-SGLLLFY5.js +9 -0
  30. package/dist/server.d.ts +67 -117
  31. package/dist/server.js +296 -626
  32. package/dist/test.d.ts +2 -2
  33. package/dist/test.js +21 -21
  34. package/dist/{text-stream-LD364KBI.js → text-stream-SOVLYR2L.js} +2 -2
  35. package/dist/{types-BqB8zC9u.d.ts → types-CG-kPI81.d.ts} +3 -1
  36. package/dist/{types-DdZw4GRQ.d.ts → types-ht-Zw3Wv.d.ts} +1 -1
  37. package/docs/agent-integration.md +19 -64
  38. package/docs/architecture.md +80 -106
  39. package/docs/customization.md +49 -66
  40. package/docs/development.md +18 -17
  41. package/docs/errors.md +4 -4
  42. package/docs/getting-started.md +74 -93
  43. package/docs/media-and-audio.md +9 -7
  44. package/docs/performance.md +11 -6
  45. package/docs/persistence.md +3 -7
  46. package/docs/production.md +4 -15
  47. package/docs/prompt-and-input.md +1 -2
  48. package/docs/provider-integration.md +156 -181
  49. package/docs/reference/protocol.md +5 -14
  50. package/docs/reference/provider-adapters.md +21 -13
  51. package/docs/security.md +1 -14
  52. package/docs/testing.md +56 -123
  53. package/package.json +4 -25
  54. package/starters/video-chat/.env.example +12 -4
  55. package/starters/video-chat/.env.native.example +13 -0
  56. package/starters/video-chat/README.md +72 -11
  57. package/starters/video-chat/package.json +1 -1
  58. package/starters/video-chat/providers/text-native.ts +94 -0
  59. package/starters/video-chat/providers/text.ts +19 -0
  60. package/starters/video-chat/providers/transcription.ts +32 -0
  61. package/starters/video-chat/providers/video-custom.ts +45 -0
  62. package/starters/video-chat/providers/video-delivery.ts +57 -0
  63. package/starters/video-chat/providers/video-google.ts +48 -0
  64. package/starters/video-chat/providers/video-job.ts +103 -0
  65. package/starters/video-chat/providers/video-runway.ts +45 -0
  66. package/starters/video-chat/providers/video.ts +39 -63
  67. package/starters/video-chat/server.ts +3 -29
  68. package/starters/video-chat/vite.config.ts +2 -8
  69. package/styles/video-chat.css +8 -105
  70. package/dist/builtin-server-W4PLJUZ5.js +0 -8
  71. package/dist/catalog-types-WTbLP6Jh.d.ts +0 -78
  72. package/dist/check-runtime.d.ts +0 -15
  73. package/dist/check-runtime.js +0 -96
  74. package/dist/chunk-2E6T633S.js +0 -27
  75. package/dist/chunk-4YM2M62S.js +0 -13
  76. package/dist/chunk-4ZJLPHBV.js +0 -684
  77. package/dist/chunk-5JBMYQP6.js +0 -156
  78. package/dist/chunk-73NTSFFI.js +0 -81
  79. package/dist/chunk-EGVQODKU.js +0 -83
  80. package/dist/chunk-HFVNAPHZ.js +0 -35
  81. package/dist/chunk-IIN5M5HW.js +0 -697
  82. package/dist/chunk-IQMYK5DX.js +0 -133
  83. package/dist/chunk-IR44XKBI.js +0 -46
  84. package/dist/chunk-JKVOBTRO.js +0 -145
  85. package/dist/chunk-LVM5Q2DL.js +0 -68
  86. package/dist/chunk-M4QTEJTK.js +0 -709
  87. package/dist/chunk-QSBDB4J2.js +0 -16
  88. package/dist/chunk-R3XAOMKP.js +0 -29
  89. package/dist/chunk-SPVTJH3F.js +0 -24
  90. package/dist/chunk-YAHT3LST.js +0 -663
  91. package/dist/chunk-ZD2VTUYR.js +0 -28
  92. package/dist/cinema-media-HYCDG65Z.js +0 -11
  93. package/dist/comparison-XXAP4S4J.js +0 -36
  94. package/dist/editorial-timeline-VUQC6KPA.js +0 -42
  95. package/dist/key-figure-4TJK7HLT.js +0 -29
  96. package/dist/kit-DrRpdn0p.d.ts +0 -79
  97. package/dist/mobile-message-FIHAO6XC.js +0 -49
  98. package/dist/preload-media-LJXWKTWG.js +0 -57
  99. package/dist/quote-GRPIYKQJ.js +0 -32
  100. package/dist/system-prompt-AG26KJAA.js +0 -12
  101. package/dist/template-catalog.d.ts +0 -400
  102. package/dist/template-catalog.js +0 -6
  103. package/dist/templates.d.ts +0 -25
  104. package/dist/templates.js +0 -102
  105. package/dist/validate-4OUD2CLX.js +0 -10
  106. package/docs/concepts.md +0 -107
  107. package/docs/custom-templates.md +0 -346
  108. package/docs/immersive-interface.md +0 -86
  109. package/docs/motion-and-effects.md +0 -109
  110. package/docs/reference/design-system.html +0 -125
  111. package/docs/responsive-orientation.md +0 -37
  112. package/docs/streaming-protocol.md +0 -15
  113. package/examples/custom-template/README.md +0 -19
  114. package/examples/custom-template/minimal-text.tsx +0 -82
  115. package/examples/custom-template/structured-data.tsx +0 -104
  116. package/registry/items/backgrounds.json +0 -70
  117. package/registry/items/chapterTitle.json +0 -80
  118. package/registry/items/cinemaMedia.json +0 -136
  119. package/registry/items/comparison.json +0 -168
  120. package/registry/items/editorialTimeline.json +0 -172
  121. package/registry/items/keyFigure.json +0 -162
  122. package/registry/items/mobileMessage.json +0 -153
  123. package/registry/items/motion.json +0 -45
  124. package/registry/items/quote.json +0 -161
  125. package/registry/items/template-context.json +0 -31
  126. package/registry/items/theme.json +0 -47
  127. package/registry/items/typography.json +0 -47
@@ -0,0 +1,45 @@
1
+ import type { VideoChatHandlerOptions } from "@vanillaskyai/video/server";
2
+ import type { DeliverVideo } from "./video-delivery";
3
+ import { runVideoJob } from "./video-job";
4
+
5
+ // Your model, duration, resolution and account throughput live in application code.
6
+ const CLIP_DURATION_SEC = 5;
7
+ const CONCURRENCY = 2;
8
+ const TIMEOUT_MS = 120_000;
9
+ type Context = Parameters<NonNullable<VideoChatHandlerOptions["generateVideo"]>>[1];
10
+
11
+ /** Any vendor SDK or native API can implement these callbacks. Submission is
12
+ * called ONCE; null means still pending. Supply your model/resolution in submit,
13
+ * persist its ID with onSubmitted, and deliver a browser-safe copy of the bytes.
14
+ */
15
+ export function createCustomVideo<Job extends { id: string }>(adapter: {
16
+ submit: (query: string, context: Context) => Promise<Job>;
17
+ poll: (job: Job, signal: AbortSignal) => Promise<Response | null>;
18
+ cancel?: (job: Job, signal: AbortSignal) => Promise<void>;
19
+ onSubmitted?: (job: Job) => void | Promise<void>;
20
+ deliver: DeliverVideo;
21
+ }): NonNullable<VideoChatHandlerOptions["generateVideo"]> {
22
+ return async (query, context) => {
23
+ if (context.requestedDurationSec !== CLIP_DURATION_SEC) throw new Error("Clip duration does not match the app-owned model policy");
24
+ let pending = true;
25
+ const { value } = await runVideoJob({
26
+ ...adapter, ...context, deadlineAt: Math.min(context.deadlineAt, Date.now() + TIMEOUT_MS), pollIntervalMs: 5000,
27
+ submit: (signal) => adapter.submit(query, { ...context, signal }),
28
+ poll: async (job, signal) => {
29
+ const response = await adapter.poll(job, signal);
30
+ if (!response) return null;
31
+ pending = false;
32
+ return { url: await adapter.deliver({ response, jobId: job.id, durationSec: context.requestedDurationSec, signal }),
33
+ type: "video" as const, durationSec: context.requestedDurationSec };
34
+ },
35
+ cancel: async (job, signal) => { if (pending) await adapter.cancel?.(job, signal); },
36
+ });
37
+ return value;
38
+ };
39
+ }
40
+
41
+ export const videoProvider = {
42
+ generatedClipDurationSec: CLIP_DURATION_SEC, mediaConcurrency: CONCURRENCY, generateVideoTimeoutMs: TIMEOUT_MS,
43
+ // Wire createCustomVideo(...) here. No vendor or storage is imposed by the SDK.
44
+ generateVideo: undefined,
45
+ } satisfies Pick<VideoChatHandlerOptions, "generateVideo" | "generatedClipDurationSec" | "mediaConcurrency" | "generateVideoTimeoutMs">;
@@ -0,0 +1,57 @@
1
+ /** Replace this callback with your own storage integration. It receives server
2
+ * bytes; provider credentials and private URLs never reach a browser. Serve
3
+ * H.264 MP4 with Content-Type, Content-Length, byte ranges, CORS and replay-safe
4
+ * retention. Put the MP4 moov atom first for progressive playback.
5
+ */
6
+ export type DeliverVideo = (input: {
7
+ response: Response; jobId: string; durationSec: number; signal: AbortSignal;
8
+ }) => Promise<string>;
9
+
10
+ function configured(): boolean {
11
+ try {
12
+ const url = new URL(process.env.VIDEO_UPLOAD_URL ?? "");
13
+ return url.protocol === "https:" && !url.username && !url.password && Boolean(process.env.VIDEO_STORAGE_TOKEN);
14
+ }
15
+ catch { return false; }
16
+ }
17
+ export const videoDeliveryConfigured = configured();
18
+
19
+ /** YOUR endpoint contract, not an SDK service: PUT MP4 bytes, return { url }.
20
+ * Or replace this whole callback with your S3/R2/local storage code.
21
+ */
22
+ export const deliverVideo: DeliverVideo = async ({ response, jobId, signal }) => {
23
+ if (!videoDeliveryConfigured) throw new Error("Configure app-owned video delivery before generation");
24
+ if (!response.ok || !response.body) throw new Error("Provider video download failed");
25
+ const upload = new URL(process.env.VIDEO_UPLOAD_URL!);
26
+ if (upload.protocol !== "https:") throw new Error("Video upload endpoint must use HTTPS");
27
+ upload.searchParams.set("jobId", jobId);
28
+ const result = await fetch(upload, {
29
+ method: "PUT", signal,
30
+ headers: { authorization: `Bearer ${process.env.VIDEO_STORAGE_TOKEN}`, "content-type": "video/mp4" },
31
+ body: response.body, duplex: "half",
32
+ } as RequestInit);
33
+ if (!result.ok) throw new Error("App-owned video upload failed");
34
+ const { url } = await result.json() as { url: string };
35
+ const publicUrl = new URL(url);
36
+ if (publicUrl.protocol !== "https:" || publicUrl.username || publicUrl.password) throw new Error("Delivery must return a browser-safe HTTPS URL");
37
+ return publicUrl.href;
38
+ };
39
+
40
+ /** Follow provider/CDN redirects without forwarding Google's key off its origin. */
41
+ export async function downloadVideo(url: string, signal: AbortSignal, googleKey?: string): Promise<Response> {
42
+ for (let redirects = 0; redirects <= 5; redirects++) {
43
+ const target = new URL(url);
44
+ if (target.protocol !== "https:" || target.username || target.password) throw new Error("Unexpected video download URL");
45
+ const response = await fetch(target.href, { signal, redirect: "manual",
46
+ headers: googleKey && target.origin === "https://generativelanguage.googleapis.com" ? { "x-goog-api-key": googleKey } : undefined });
47
+ if (![301, 302, 303, 307, 308].includes(response.status)) {
48
+ if (!response.ok) throw new Error("Provider video download failed");
49
+ return response;
50
+ }
51
+ const location = response.headers.get("location");
52
+ await response.body?.cancel();
53
+ if (!location) throw new Error("Video download redirect has no location");
54
+ url = new URL(location, target).href;
55
+ }
56
+ throw new Error("Too many video download redirects");
57
+ }
@@ -0,0 +1,48 @@
1
+ import type { VideoChatHandlerOptions } from "@vanillaskyai/video/server";
2
+ import { deliverVideo, downloadVideo, videoDeliveryConfigured, type DeliverVideo } from "./video-delivery";
3
+ import { jsonResponse, providerUrl, runVideoJob, videoPrompt } from "./video-job";
4
+
5
+ // https://ai.google.dev/gemini-api/docs/video — Veo 3.1: 4/6/8s at 720p.
6
+ const VIDEO_MODEL = process.env.GOOGLE_VIDEO_MODEL ?? "veo-3.1-fast-generate-preview";
7
+ const CLIP_DURATION_SEC = 6;
8
+ const RESOLUTION = "720p";
9
+ const CONCURRENCY = 2;
10
+ const TIMEOUT_MS = 360_000;
11
+ const BASE = "https://generativelanguage.googleapis.com/v1beta";
12
+
13
+ export function createGoogleVideo(apiKey: string, delivery: DeliverVideo): NonNullable<VideoChatHandlerOptions["generateVideo"]> {
14
+ const headers = { "x-goog-api-key": apiKey, "content-type": "application/json" };
15
+ return async (query, context) => {
16
+ if (context.requestedDurationSec !== CLIP_DURATION_SEC) throw new Error("Clip duration does not match the app-owned model policy");
17
+ const { value } = await runVideoJob({
18
+ ...context, deadlineAt: Math.min(context.deadlineAt, Date.now() + TIMEOUT_MS), pollIntervalMs: 10_000,
19
+ submit: async (signal) => {
20
+ const result = await jsonResponse<{ name: string }>(await fetch(`${BASE}/models/${VIDEO_MODEL}:predictLongRunning`, {
21
+ method: "POST", headers, signal,
22
+ body: JSON.stringify({ instances: [{ prompt: videoPrompt(query, context) }], parameters: {
23
+ durationSeconds: CLIP_DURATION_SEC, resolution: RESOLUTION, aspectRatio: context.orientation === "portrait" ? "9:16" : "16:9",
24
+ } }),
25
+ }));
26
+ return { id: result.name };
27
+ },
28
+ poll: async (job, signal) => {
29
+ if (!/^models\/[\w.-]+\/operations\/[\w.-]+$/.test(job.id)) throw new Error("Unexpected video operation name");
30
+ const status = await jsonResponse<{ done?: boolean; error?: unknown; response?: { generateVideoResponse?: { generatedSamples?: { video: { uri: string } }[] } } }>(await fetch(`${BASE}/${job.id}`, { headers, signal }));
31
+ if (status.error) throw new Error("Video generation failed");
32
+ if (!status.done) return null;
33
+ const uri = status.response?.generateVideoResponse?.generatedSamples?.[0]?.video.uri;
34
+ if (!uri) throw new Error("Video generation returned no output");
35
+ // Download is private; keep it and storage delivery inside the deadline.
36
+ const response = await downloadVideo(providerUrl(uri, "https://generativelanguage.googleapis.com"), signal, apiKey);
37
+ return { url: await delivery({ response, jobId: job.id, durationSec: CLIP_DURATION_SEC, signal }), type: "video" as const, durationSec: CLIP_DURATION_SEC };
38
+ },
39
+ // No documented Gemini Veo cancellation. Abort stops polling, not billing.
40
+ });
41
+ return value;
42
+ };
43
+ }
44
+
45
+ export const videoProvider = {
46
+ generatedClipDurationSec: CLIP_DURATION_SEC, mediaConcurrency: CONCURRENCY, generateVideoTimeoutMs: TIMEOUT_MS,
47
+ generateVideo: process.env.GEMINI_API_KEY && videoDeliveryConfigured ? createGoogleVideo(process.env.GEMINI_API_KEY, deliverVideo) : undefined,
48
+ } satisfies Pick<VideoChatHandlerOptions, "generateVideo" | "generatedClipDurationSec" | "mediaConcurrency" | "generateVideoTimeoutMs">;
@@ -0,0 +1,103 @@
1
+ /** App-owned queue lifecycle. No automatic resubmission of paid work. */
2
+ export async function runVideoJob<Job extends { id: string }, Value>(options: {
3
+ signal: AbortSignal;
4
+ deadlineAt: number;
5
+ pollIntervalMs: number;
6
+ submit: (signal: AbortSignal) => Promise<Job>;
7
+ poll: (job: Job, signal: AbortSignal) => Promise<Value | null>;
8
+ cancel?: (job: Job, signal: AbortSignal) => Promise<void>;
9
+ /** Persist this ID in your job ledger if work must survive a server restart. */
10
+ onSubmitted?: (job: Job, signal: AbortSignal) => void | Promise<void>;
11
+ }): Promise<{ job: Job; value: Value }> {
12
+ options.signal.throwIfAborted();
13
+ const remaining = options.deadlineAt - Date.now();
14
+ if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("Video deadline expired before submission");
15
+ const timer = new AbortController();
16
+ const timeout = setTimeout(() => timer.abort(new Error("Video deadline expired")), remaining);
17
+ const signal = AbortSignal.any([options.signal, timer.signal]);
18
+ let job: Job | undefined;
19
+ let stopped = false;
20
+ let cancellation: Promise<void> | undefined;
21
+ const cancelJob = (): Promise<void> => {
22
+ if (!job || !options.cancel) return Promise.resolve();
23
+ const accepted = job;
24
+ return cancellation ??= (async () => {
25
+ // A fresh signal permits cleanup after the request's signal has stopped.
26
+ const cleanup = new AbortController();
27
+ const cleanupTimer = setTimeout(() => cleanup.abort(new Error("Video cancellation timed out")), 3000);
28
+ try { await withSignal(() => options.cancel!(accepted, cleanup.signal), cleanup.signal); }
29
+ catch { /* Best effort: cancellation promises no refund. */ }
30
+ finally { clearTimeout(cleanupTimer); }
31
+ })();
32
+ };
33
+ try {
34
+ // A transport error here can mean the provider accepted the job. NEVER retry.
35
+ job = await withSignal(async () => {
36
+ const accepted = await options.submit(signal);
37
+ if (typeof accepted?.id !== "string" || !accepted.id) throw new Error("Missing provider job ID");
38
+ job = accepted;
39
+ // An uncooperative submit may reveal its ID after our bounded wait ends.
40
+ if (stopped) void cancelJob();
41
+ return accepted;
42
+ }, signal);
43
+ if (options.onSubmitted) await withSignal(() => options.onSubmitted!(job!, signal), signal);
44
+ while (true) {
45
+ signal.throwIfAborted();
46
+ const value = await withSignal(() => options.poll(job!, signal), signal);
47
+ signal.throwIfAborted();
48
+ if (value !== null) return { job, value };
49
+ await pause(options.pollIntervalMs, signal);
50
+ }
51
+ } catch {
52
+ stopped = true;
53
+ // A failed callback abandons this job, too. Stop sibling work and attempt
54
+ // cleanup; adapters decide whether an already-completed job is cancellable.
55
+ timer.abort(new Error("Video job stopped"));
56
+ await cancelJob();
57
+ throw Object.assign(new Error(job
58
+ ? "Video job did not complete; inspect the retained job ID before retrying"
59
+ : "Video submission outcome unknown; inspect the provider dashboard before retrying"), { jobId: job?.id });
60
+ } finally {
61
+ clearTimeout(timeout);
62
+ }
63
+ }
64
+
65
+ /** Bound application promises even when they ignore the cancellation signal. */
66
+ async function withSignal<T>(operation: () => T | PromiseLike<T>, signal: AbortSignal): Promise<T> {
67
+ signal.throwIfAborted();
68
+ return new Promise<T>((resolve, reject) => {
69
+ const cleanup = () => signal.removeEventListener("abort", abort);
70
+ const abort = () => { cleanup(); reject(signal.reason); };
71
+ signal.addEventListener("abort", abort, { once: true });
72
+ Promise.resolve().then(() => { signal.throwIfAborted(); return operation(); }).then(
73
+ (value) => { cleanup(); resolve(value); },
74
+ (error) => { cleanup(); reject(error); },
75
+ );
76
+ });
77
+ }
78
+
79
+ async function pause(ms: number, signal: AbortSignal): Promise<void> {
80
+ signal.throwIfAborted();
81
+ await new Promise<void>((resolve, reject) => {
82
+ const abort = () => { clearTimeout(timer); reject(signal.reason); };
83
+ const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
84
+ signal.addEventListener("abort", abort, { once: true });
85
+ });
86
+ }
87
+
88
+ /** Keep provider response bodies, URLs and credentials out of errors. */
89
+ export async function jsonResponse<T>(response: Response): Promise<T> {
90
+ if (!response.ok) throw new Error(`Provider HTTP ${response.status}`);
91
+ return await response.json() as T;
92
+ }
93
+
94
+ export function providerUrl(value: string, origin: string): string {
95
+ const url = new URL(value);
96
+ if (url.origin !== origin || url.username || url.password) throw new Error("Unexpected provider URL");
97
+ return url.href;
98
+ }
99
+
100
+ export function videoPrompt(query: string, context: { shotDirection: string; generatedLook?: string }): string {
101
+ return [query, context.shotDirection, context.generatedLook,
102
+ "One continuous moving shot. No text, logos, captions, dialogue, music or narration."].filter(Boolean).join("\n");
103
+ }
@@ -0,0 +1,45 @@
1
+ import type { VideoChatHandlerOptions } from "@vanillaskyai/video/server";
2
+ import { deliverVideo, downloadVideo, videoDeliveryConfigured, type DeliverVideo } from "./video-delivery";
3
+ import { jsonResponse, runVideoJob, videoPrompt } from "./video-job";
4
+
5
+ // https://docs.dev.runwayml.com/api/ — Gen-4.5: integer durations 2–10s.
6
+ const VIDEO_MODEL = process.env.RUNWAY_VIDEO_MODEL ?? "gen4.5";
7
+ const CLIP_DURATION_SEC = 5;
8
+ const RESOLUTION = { portrait: "720:1280", landscape: "1280:720" };
9
+ const CONCURRENCY = 2;
10
+ const TIMEOUT_MS = 180_000;
11
+ const BASE = "https://api.dev.runwayml.com/v1";
12
+
13
+ export function createRunwayVideo(apiKey: string, delivery: DeliverVideo): NonNullable<VideoChatHandlerOptions["generateVideo"]> {
14
+ const headers = { Authorization: `Bearer ${apiKey}`, "X-Runway-Version": "2024-11-06", "content-type": "application/json" };
15
+ return async (query, context) => {
16
+ if (context.requestedDurationSec !== CLIP_DURATION_SEC) throw new Error("Clip duration does not match the app-owned model policy");
17
+ const promptText = videoPrompt(query, context);
18
+ if (promptText.length > 1000) throw new Error("Runway video prompt exceeds 1000 characters");
19
+ let pending = true;
20
+ const { value } = await runVideoJob({
21
+ ...context, deadlineAt: Math.min(context.deadlineAt, Date.now() + TIMEOUT_MS), pollIntervalMs: 5000,
22
+ submit: async (signal) => jsonResponse<{ id: string }>(await fetch(`${BASE}/text_to_video`, {
23
+ method: "POST", headers, signal,
24
+ body: JSON.stringify({ model: VIDEO_MODEL, promptText, duration: CLIP_DURATION_SEC, ratio: RESOLUTION[context.orientation] }),
25
+ })),
26
+ poll: async (job, signal) => {
27
+ const status = await jsonResponse<{ status: string; output?: string[] }>(await fetch(`${BASE}/tasks/${encodeURIComponent(job.id)}`, { headers, signal }));
28
+ if (["PENDING", "THROTTLED", "RUNNING"].includes(status.status)) return null;
29
+ // DELETE removes terminal tasks too; preserve their status and cost.
30
+ if (["SUCCEEDED", "FAILED", "CANCELLED"].includes(status.status)) pending = false;
31
+ if (status.status !== "SUCCEEDED" || !status.output?.[0]) throw new Error("Video generation failed");
32
+ const response = await downloadVideo(status.output[0], signal);
33
+ return { url: await delivery({ response, jobId: job.id, durationSec: CLIP_DURATION_SEC, signal }), type: "video" as const, durationSec: CLIP_DURATION_SEC };
34
+ },
35
+ // DELETE cancels pending/running jobs; completion racing it may delete the task.
36
+ cancel: async (job, signal) => { if (pending) await fetch(`${BASE}/tasks/${encodeURIComponent(job.id)}`, { method: "DELETE", headers, signal }); },
37
+ });
38
+ return value;
39
+ };
40
+ }
41
+
42
+ export const videoProvider = {
43
+ generatedClipDurationSec: CLIP_DURATION_SEC, mediaConcurrency: CONCURRENCY, generateVideoTimeoutMs: TIMEOUT_MS,
44
+ generateVideo: process.env.RUNWAY_API_KEY && videoDeliveryConfigured ? createRunwayVideo(process.env.RUNWAY_API_KEY, deliverVideo) : undefined,
45
+ } satisfies Pick<VideoChatHandlerOptions, "generateVideo" | "generatedClipDurationSec" | "mediaConcurrency" | "generateVideoTimeoutMs">;
@@ -1,71 +1,47 @@
1
- import { fal } from "@fal-ai/client";
2
1
  import type { VideoChatHandlerOptions } from "@vanillaskyai/video/server";
2
+ import { deliverVideo, downloadVideo, videoDeliveryConfigured, type DeliverVideo } from "./video-delivery";
3
+ import { jsonResponse, providerUrl, runVideoJob, videoPrompt } from "./video-job";
3
4
 
4
- // Change only to a duration supported by the selected model and your allowance.
5
- const CLIP_DURATION_SEC = 5;
5
+ // App-owned model policy: change these together after checking the model schema.
6
+ // https://fal.ai/models/minimax/h3-max-turbo/text-to-video/api
6
7
  const VIDEO_MODEL = process.env.FAL_VIDEO_MODEL ?? "minimax/h3-max-turbo/text-to-video";
8
+ const CLIP_DURATION_SEC = 5;
9
+ const RESOLUTION = "480P";
10
+ const CONCURRENCY = 3;
11
+ const TIMEOUT_MS = 120_000;
7
12
 
8
- async function filmScene(
9
- subject: string,
10
- generatedLook: string | undefined,
11
- orientation: "portrait" | "landscape",
12
- signal: AbortSignal,
13
- shotDirection?: string,
14
- ) {
15
- fal.config({ credentials: process.env.FAL_KEY });
16
- const started = Date.now();
17
- try {
18
- const result = await fal.subscribe(VIDEO_MODEL, {
19
- input: {
20
- prompt: [
21
- `One continuous moving shot, ${orientation === "portrait" ? "9:16 vertical" : "16:9"}. ${subject}.`,
22
- "Depict the requested action clearly throughout the clip. Follow its stated visual style and world rules.",
23
- shotDirection,
24
- "No on-screen text, captions, subtitles, watermarks or logos.",
25
- "Natural scene sound only when appropriate. No speech, dialogue, singing, music or voiceover; narration is added separately.",
26
- generatedLook,
27
- ].filter(Boolean).join("\n\n"),
28
- duration: CLIP_DURATION_SEC,
29
- resolution: "480P",
30
- aspect_ratio: orientation === "portrait" ? "9:16" : "16:9",
31
- prompt_expansion_mode: "balanced",
13
+ export function createFalVideo(apiKey: string, delivery: DeliverVideo): NonNullable<VideoChatHandlerOptions["generateVideo"]> {
14
+ const headers = { Authorization: `Key ${apiKey}`, "content-type": "application/json" };
15
+ return async (query, context) => {
16
+ if (context.requestedDurationSec !== CLIP_DURATION_SEC) throw new Error("Clip duration does not match the app-owned model policy");
17
+ let pending = true;
18
+ const { value } = await runVideoJob({
19
+ ...context, deadlineAt: Math.min(context.deadlineAt, Date.now() + TIMEOUT_MS), pollIntervalMs: 1000,
20
+ submit: async (signal) => {
21
+ const result = await jsonResponse<{ request_id: string; status_url: string; response_url: string; cancel_url: string }>(await fetch(`https://queue.fal.run/${VIDEO_MODEL}`, {
22
+ method: "POST", headers, signal,
23
+ body: JSON.stringify({ prompt: videoPrompt(query, context), duration: CLIP_DURATION_SEC, resolution: RESOLUTION,
24
+ aspect_ratio: context.orientation === "portrait" ? "9:16" : "16:9", prompt_expansion_mode: "balanced" }),
25
+ }));
26
+ return { id: result.request_id, statusUrl: result.status_url, resultUrl: result.response_url, cancelUrl: result.cancel_url };
27
+ },
28
+ poll: async (job, signal) => {
29
+ const status = await jsonResponse<{ status: string; error?: unknown }>(await fetch(providerUrl(job.statusUrl, "https://queue.fal.run"), { headers, signal }));
30
+ if (status.error) throw new Error("Video generation failed");
31
+ if (["IN_QUEUE", "IN_PROGRESS"].includes(status.status)) return null;
32
+ if (status.status !== "COMPLETED") throw new Error("Unexpected video job status");
33
+ pending = false;
34
+ const result = await jsonResponse<{ video: { url: string } }>(await fetch(providerUrl(job.resultUrl, "https://queue.fal.run"), { headers, signal }));
35
+ const response = await downloadVideo(result.video.url, signal);
36
+ return { url: await delivery({ response, jobId: job.id, durationSec: CLIP_DURATION_SEC, signal }), type: "video" as const, durationSec: CLIP_DURATION_SEC };
32
37
  },
33
- abortSignal: signal,
38
+ cancel: async (job, signal) => { if (pending) await fetch(providerUrl(job.cancelUrl, "https://queue.fal.run"), { method: "PUT", headers, signal }); },
34
39
  });
35
- const url = result?.data?.video?.url;
36
- if (typeof url !== "string") throw new Error("the video provider returned no url");
37
- console.log(`[video-chat] filmed in ${Date.now() - started}ms`);
38
- return url;
39
- } catch (cause) {
40
- const status = (cause as { status?: unknown })?.status;
41
- console.error(
42
- `[video-chat] filming failed after ${Date.now() - started}ms${typeof status === "number" ? ` (status ${status})` : ""}`,
43
- );
44
- throw cause;
45
- }
40
+ return value;
41
+ };
46
42
  }
47
43
 
48
- /** Optional generated video and transcription; credentials remain server-only. */
49
- export const videoProvider: Pick<VideoChatHandlerOptions, "generateVideo" | "transcribe" | "generatedClipDurationSec"> = {
50
- generatedClipDurationSec: CLIP_DURATION_SEC,
51
- generateVideo: process.env.FAL_KEY
52
- ? async (query, { generatedLook, orientation, signal, scene }) => ({
53
- url: await filmScene(query, generatedLook, orientation, signal, typeof scene?.variables.shotDirection === "string" ? scene.variables.shotDirection : undefined),
54
- type: "video" as const,
55
- })
56
- : undefined,
57
- transcribe: process.env.FAL_KEY
58
- ? async ({ audio, mediaType, signal }) => {
59
- signal.throwIfAborted();
60
- fal.config({ credentials: process.env.FAL_KEY });
61
- const bytes = Uint8Array.from(audio);
62
- const url = await fal.storage.upload(new Blob([bytes.buffer], { type: mediaType }));
63
- signal.throwIfAborted();
64
- const result = await fal.subscribe(process.env.FAL_TRANSCRIBE_MODEL ?? "fal-ai/whisper", {
65
- input: { audio_url: url, task: "transcribe" },
66
- abortSignal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]),
67
- });
68
- return String((result?.data as { text?: unknown })?.text ?? "");
69
- }
70
- : undefined,
71
- };
44
+ export const videoProvider = {
45
+ generatedClipDurationSec: CLIP_DURATION_SEC, mediaConcurrency: CONCURRENCY, generateVideoTimeoutMs: TIMEOUT_MS,
46
+ generateVideo: process.env.FAL_KEY && videoDeliveryConfigured ? createFalVideo(process.env.FAL_KEY, deliverVideo) : undefined,
47
+ } satisfies Pick<VideoChatHandlerOptions, "generateVideo" | "generatedClipDurationSec" | "mediaConcurrency" | "generateVideoTimeoutMs">;
@@ -1,11 +1,8 @@
1
- import { anthropic } from "@ai-sdk/anthropic";
2
- import { generateText, streamText } from "ai";
3
1
  import { createVideoChatHandler } from "@vanillaskyai/video/server";
4
2
  import { findStockFootage } from "./stock";
5
3
  import { providers } from "./providers";
4
+ import { textProvider } from "./providers/text";
6
5
 
7
- const PLANNER_MODEL = process.env.ANTHROPIC_PLANNER_MODEL ?? "claude-sonnet-5";
8
- const NARRATION_MODEL = process.env.ANTHROPIC_NARRATION_MODEL ?? "claude-haiku-4-5";
9
6
  /**
10
7
  * The application chooses providers and keeps their credentials here. The SDK
11
8
  * owns the video-chat protocol, prompts, capability negotiation, spend limits,
@@ -13,29 +10,7 @@ const NARRATION_MODEL = process.env.ANTHROPIC_NARRATION_MODEL ?? "claude-haiku-4
13
10
  */
14
11
  export const handleVideoChat = createVideoChatHandler({
15
12
  authorize: (request) => new URL(request.url).hostname === "localhost",
16
- streamText: ({ systemPrompt, userPrompt, signal }) => streamText({
17
- model: anthropic(PLANNER_MODEL),
18
- system: systemPrompt,
19
- prompt: userPrompt,
20
- abortSignal: signal,
21
- maxOutputTokens: 8_192,
22
- providerOptions: {
23
- anthropic: {
24
- thinking: { type: "disabled" },
25
- output_config: { effort: "medium" },
26
- },
27
- },
28
- }),
29
- generateText: async ({ systemPrompt, userPrompt, maxOutputTokens, signal }) => {
30
- const { text } = await generateText({
31
- model: anthropic(NARRATION_MODEL),
32
- system: systemPrompt,
33
- prompt: userPrompt,
34
- maxOutputTokens,
35
- abortSignal: signal,
36
- });
37
- return text;
38
- },
13
+ ...textProvider,
39
14
  searchMedia: process.env.PEXELS_API_KEY
40
15
  ? (query, { orientation, signal, scene }) => findStockFootage(query, orientation, signal, scene?.variables.stockSelection)
41
16
  : undefined,
@@ -66,7 +41,6 @@ export const handleVideoChat = createVideoChatHandler({
66
41
  },
67
42
  ],
68
43
  },
69
- onError: (error) => console.error("[video-chat] planning failed:", error.message),
44
+ onError: () => console.error("[video-chat] planning failed"),
70
45
  onWarning: (warning) => console.warn(`[video-chat] ${warning.code}: ${warning.message}`),
71
- mediaConcurrency: 5,
72
46
  });
@@ -12,11 +12,6 @@ function videoChatRoutes(): Plugin {
12
12
  const path = url.pathname;
13
13
  if (path !== "/api/video-chat") return next();
14
14
  const action = url.searchParams.get("action");
15
- if (!process.env.ANTHROPIC_API_KEY && action !== "welcome" && action !== "capabilities") {
16
- response.statusCode = 503;
17
- response.end("Set ANTHROPIC_API_KEY and restart. Video responses require a text provider.");
18
- return;
19
- }
20
15
 
21
16
  const startedAt = Date.now();
22
17
  const since = () => `${String(Date.now() - startedAt).padStart(6)}ms`;
@@ -63,9 +58,8 @@ function videoChatRoutes(): Plugin {
63
58
  }
64
59
  server.config.logger.info(`[video-chat] ${since()} ${action ?? "unknown"} complete`);
65
60
  response.end();
66
- } catch (cause) {
67
- const message = cause instanceof Error ? cause.message : String(cause);
68
- server.config.logger.error(`[video-chat] ${action ?? "unknown"} failed: ${message}`);
61
+ } catch {
62
+ server.config.logger.error(`[video-chat] ${action ?? "unknown"} failed`);
69
63
  if (!response.headersSent) response.statusCode = 500;
70
64
  response.end("Video chat request failed");
71
65
  }