@vanillaskyai/video 0.10.22 → 0.11.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.
- package/CHANGELOG.md +74 -1
- package/PUBLIC-API.md +45 -438
- package/README.md +57 -96
- package/dist/builtin-metadata-OT6V7TB4.js +8 -0
- package/dist/{chapter-title-2JVDU62E.js → chapter-title-2RCXX7SN.js} +1 -2
- package/dist/{chunk-PDQFQIQW.js → chunk-3EQ6PVWL.js} +1 -1
- package/dist/chunk-4G4JBMCM.js +37 -0
- package/dist/{chunk-7AA2JWHZ.js → chunk-5DOQTIMD.js} +6 -6
- package/dist/chunk-666HGTVZ.js +41 -0
- package/dist/{chunk-7M56IUUX.js → chunk-6Z3ID54H.js} +0 -18
- package/dist/{chunk-5BSJLT6H.js → chunk-E7FHBLAC.js} +16 -29
- package/dist/chunk-HIQM4J3U.js +30 -0
- package/dist/chunk-ISWQ6T5X.js +36 -0
- package/dist/{chunk-224QNWRA.js → chunk-K5J7ESRO.js} +1 -10
- package/dist/chunk-KMVRBUL5.js +50 -0
- package/dist/chunk-NM4CXXZY.js +25 -0
- package/dist/{chunk-44WND2VP.js → chunk-V2CP7PVY.js} +19 -63
- package/dist/{chunk-3O7OMMMF.js → chunk-WZESNEPT.js} +0 -1
- package/dist/{chunk-RXTN2CW6.js → chunk-Z3DLSLAJ.js} +1 -1
- package/dist/cinema-media-YZ2UANJG.js +365 -0
- package/dist/cli.js +154 -1753
- package/dist/{compose-video-BH5K6XWT.js → compose-video-H2YUQDUY.js} +4 -4
- package/dist/{events-B4YCc4vc.d.ts → events-BZAfl0Dm.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -6
- package/dist/preload-media-23JXUFCF.js +77 -0
- package/dist/react.d.ts +39 -15
- package/dist/react.js +1350 -739
- package/dist/scene-validation-SGLLLFY5.js +9 -0
- package/dist/server.d.ts +67 -117
- package/dist/server.js +262 -614
- package/dist/test.d.ts +2 -2
- package/dist/test.js +21 -21
- package/dist/{text-stream-LD364KBI.js → text-stream-SOVLYR2L.js} +2 -2
- package/dist/{types-BqB8zC9u.d.ts → types-CG-kPI81.d.ts} +3 -1
- package/dist/{types-DdZw4GRQ.d.ts → types-ht-Zw3Wv.d.ts} +1 -1
- package/docs/agent-integration.md +19 -64
- package/docs/architecture.md +82 -87
- package/docs/customization.md +49 -66
- package/docs/development.md +18 -17
- package/docs/errors.md +4 -4
- package/docs/getting-started.md +74 -93
- package/docs/media-and-audio.md +9 -7
- package/docs/performance.md +11 -6
- package/docs/persistence.md +3 -7
- package/docs/production.md +4 -15
- package/docs/prompt-and-input.md +1 -2
- package/docs/provider-integration.md +134 -181
- package/docs/reference/protocol.md +5 -14
- package/docs/reference/provider-adapters.md +21 -13
- package/docs/security.md +1 -14
- package/docs/testing.md +56 -123
- package/package.json +5 -27
- package/starters/video-chat/.env.example +12 -4
- package/starters/video-chat/.env.native.example +13 -0
- package/starters/video-chat/README.md +72 -11
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/providers/text-native.ts +94 -0
- package/starters/video-chat/providers/text.ts +19 -0
- package/starters/video-chat/providers/transcription.ts +32 -0
- package/starters/video-chat/providers/video-custom.ts +45 -0
- package/starters/video-chat/providers/video-delivery.ts +57 -0
- package/starters/video-chat/providers/video-google.ts +48 -0
- package/starters/video-chat/providers/video-job.ts +103 -0
- package/starters/video-chat/providers/video-runway.ts +45 -0
- package/starters/video-chat/providers/video.ts +39 -63
- package/starters/video-chat/server.ts +3 -29
- package/starters/video-chat/vite.config.ts +2 -8
- package/styles/video-chat.css +8 -105
- package/dist/builtin-server-W4PLJUZ5.js +0 -8
- package/dist/catalog-types-WTbLP6Jh.d.ts +0 -78
- package/dist/check-runtime.d.ts +0 -15
- package/dist/check-runtime.js +0 -96
- package/dist/chunk-2E6T633S.js +0 -27
- package/dist/chunk-4YM2M62S.js +0 -13
- package/dist/chunk-4ZJLPHBV.js +0 -684
- package/dist/chunk-5JBMYQP6.js +0 -156
- package/dist/chunk-73NTSFFI.js +0 -81
- package/dist/chunk-EGVQODKU.js +0 -83
- package/dist/chunk-HFVNAPHZ.js +0 -35
- package/dist/chunk-IIN5M5HW.js +0 -697
- package/dist/chunk-IQMYK5DX.js +0 -133
- package/dist/chunk-IR44XKBI.js +0 -46
- package/dist/chunk-JKVOBTRO.js +0 -145
- package/dist/chunk-LVM5Q2DL.js +0 -68
- package/dist/chunk-M4QTEJTK.js +0 -709
- package/dist/chunk-QSBDB4J2.js +0 -16
- package/dist/chunk-R3XAOMKP.js +0 -29
- package/dist/chunk-SPVTJH3F.js +0 -24
- package/dist/chunk-YAHT3LST.js +0 -663
- package/dist/chunk-ZD2VTUYR.js +0 -28
- package/dist/cinema-media-HYCDG65Z.js +0 -11
- package/dist/comparison-XXAP4S4J.js +0 -36
- package/dist/editorial-timeline-VUQC6KPA.js +0 -42
- package/dist/key-figure-4TJK7HLT.js +0 -29
- package/dist/kit-DrRpdn0p.d.ts +0 -79
- package/dist/mobile-message-FIHAO6XC.js +0 -49
- package/dist/preload-media-LJXWKTWG.js +0 -57
- package/dist/quote-GRPIYKQJ.js +0 -32
- package/dist/system-prompt-AG26KJAA.js +0 -12
- package/dist/template-catalog.d.ts +0 -400
- package/dist/template-catalog.js +0 -6
- package/dist/templates.d.ts +0 -25
- package/dist/templates.js +0 -102
- package/dist/validate-4OUD2CLX.js +0 -10
- package/docs/concepts.md +0 -107
- package/docs/custom-templates.md +0 -346
- package/docs/immersive-interface.md +0 -86
- package/docs/maintainers/cinematic-migration.md +0 -38
- package/docs/motion-and-effects.md +0 -109
- package/docs/reference/design-system.html +0 -125
- package/docs/responsive-orientation.md +0 -37
- package/docs/streaming-protocol.md +0 -15
- package/examples/custom-template/README.md +0 -19
- package/examples/custom-template/minimal-text.tsx +0 -82
- package/examples/custom-template/structured-data.tsx +0 -104
- package/registry/items/backgrounds.json +0 -70
- package/registry/items/chapterTitle.json +0 -80
- package/registry/items/cinemaMedia.json +0 -136
- package/registry/items/comparison.json +0 -168
- package/registry/items/editorialTimeline.json +0 -172
- package/registry/items/keyFigure.json +0 -162
- package/registry/items/mobileMessage.json +0 -153
- package/registry/items/motion.json +0 -45
- package/registry/items/quote.json +0 -161
- package/registry/items/template-context.json +0 -31
- package/registry/items/theme.json +0 -47
- 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
|
-
//
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
+
cancel: async (job, signal) => { if (pending) await fetch(providerUrl(job.cancelUrl, "https://queue.fal.run"), { method: "PUT", headers, signal }); },
|
|
34
39
|
});
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
|
67
|
-
|
|
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
|
}
|