Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ Cost tier: `very-high`

#### `vibe generate video-cancel`

Cancel video generation (Grok or Runway)
Cancel video generation (Runway, Seedance, Gemini Omni)

Product surface: `advanced`
Note: Provider lifecycle control.
Expand All @@ -661,12 +661,12 @@ Cost tier: `free`
**Parameters:**

- `task-id` _(string)_ **required** - Task ID to cancel
- `provider` _(string)_ _(grok \| runway)_ _(default: `"grok"`)_ - Provider: grok, runway
- `apiKey` _(string)_ - API key (or set XAI_API_KEY / RUNWAY_API_SECRET env)
- `provider` _(string)_ _(runway \| seedance \| omni)_ _(default: `"runway"`)_ - Provider: runway, seedance, omni
- `apiKey` _(string)_ - API key for the provider

#### `vibe generate video-extend`

Extend video duration (Kling by video ID, Veo by operation name)
Extend a generated video from its last frame (Kling, Grok, Gemini Omni, Veo)

Product surface: `advanced`
Note: Provider lifecycle control.
Expand All @@ -675,12 +675,12 @@ Cost tier: `very-high`

**Parameters:**

- `id` _(string)_ **required** - Kling video ID or Veo operation name
- `provider` _(string)_ _(kling \| veo)_ _(default: `"kling"`)_ - Provider: kling, veo
- `apiKey` _(string)_ - API key (KLING_API_KEY or GOOGLE_API_KEY)
- `task-id` _(string)_ **required** - Task ID the generation returned (Veo: its operation name)
- `provider` _(string)_ _(kling \| grok \| omni \| veo)_ _(default: `"kling"`)_ - Provider: kling, grok, omni, veo
- `apiKey` _(string)_ - API key for the provider
- `output` _(string)_ - Output file path
- `prompt` _(string)_ - Continuation prompt
- `duration` _(number)_ _(default: `5`)_ - Duration: 5 or 10 (Kling), 4/6/8 (Veo)
- `duration` _(number)_ _(default: `5`)_ - Seconds to add: 5 or 10 (Kling), 2-10 (Grok), 4/6/8 (Veo); Omni picks its own
- `negative` _(string)_ - Negative prompt (what to avoid, Kling only)
- `veoModel` _(string)_ _(default: `"3.1"`)_ - Veo model: 3.1, 3.1-fast
- `noWait` _(boolean)_ - Start extension and return task ID without waiting
Expand Down
11 changes: 11 additions & 0 deletions packages/ai-providers/src/fal/FalProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ describe("FalProvider (Seedance) requests", () => {
expect(state).toMatchObject({ status: "failed", error: { kind: "likeness", status: 422, code: "content_policy_violation" } });
});

it("reads a cancelled job as cancelled, not failed", async () => {
net.on("GET", QUEUE, /\/requests\/req-1\/status$/, () =>
jsonResponse({ status: "COMPLETED", request_id: "req-1", error: "Request cancelled by the client.", error_type: "client_cancelled" })
);
const fal = await create();
const job = await fal.submitVideo({ prompt: "p" });
await fal.cancelVideoJob(job);

expect(await fal.getVideoJob(job)).toEqual({ status: "cancelled" });
});

it("keeps the older blocking generateVideo working on top of the queue", async () => {
const fal = await create();
(fal as unknown as { pollingInterval: number }).pollingInterval = 1;
Expand Down
13 changes: 11 additions & 2 deletions packages/ai-providers/src/fal/FalProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ export class FalProvider implements AIProvider, VideoGenerator {
capabilities: AICapability[] = ["text-to-video", "image-to-video", "reference-to-video"];
iconUrl = "/icons/fal.svg";
isAvailable = true;
/** Data URIs are uploaded to fal storage on submit. */
readonly imageInput = "either" as const;

private client?: FalClient;
private apiKey?: string;
Expand Down Expand Up @@ -241,6 +243,8 @@ export class FalProvider implements AIProvider, VideoGenerator {

// fal: a COMPLETED status can still be a failure; check `error` first.
const failure = status as { error?: string; error_type?: string };
// A cancelled job also ends COMPLETED, with `error_type: "client_cancelled"` (probe).
if (failure.error_type === "client_cancelled") return { status: "cancelled" };
if (failure.error) {
return { status: "failed", error: classifyProviderError({ provider: this.id, message: failure.error, code: failure.error_type }).toJSON() };
}
Expand Down Expand Up @@ -322,10 +326,15 @@ export class FalProvider implements AIProvider, VideoGenerator {
return this.client;
}

/**
* The endpoint a job was submitted to. fal's status, result, and cancel
* URLs are app-level (`bytedance/seedance-2.0/requests/{id}`), so a bare
* request ID can fall back to its model's text-to-video endpoint.
*/
private endpointOf(job: VideoJob): string {
const endpoint = job.meta?.endpoint;
const endpoint = job.meta?.endpoint ?? ENDPOINT_TEXT_TO_VIDEO[job.model as SeedanceVariant];
if (!endpoint) {
throw new ProviderError({ kind: "invalid-request", provider: this.id, message: `Seedance job ${job.id} has no endpoint in its handle.` });
throw new ProviderError({ kind: "invalid-request", provider: this.id, message: `Seedance job ${job.id} names no known model.` });
}
return endpoint;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-providers/src/gemini/gemini-omni.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ function inlineImage(ref: string): { type: "image"; data: string; mime_type: str

export class OmniProvider implements VideoGenerator {
id = "omni";
/** Frames go inline in the request. */
readonly imageInput = "data-uri" as const;
label = "Gemini Omni 1.1 Flash";
private apiKey?: string;
private pollingInterval = 5000;
Expand Down
1 change: 1 addition & 0 deletions packages/ai-providers/src/gemini/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ defineProvider({
resolverPriority: { video: 3 },
commandsUnlocked: ["generate video -p omni"],
});
export * from "./veo-generator.js";
71 changes: 71 additions & 0 deletions packages/ai-providers/src/gemini/veo-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Veo 3.1 on the video job contract, as a thin wrapper over
* `GeminiProvider`'s Veo methods. Every Veo 3.1 model shuts down on
* 2026-10-22, so this exists only so callers have no Veo-specific branch
* until then; it is deleted together with Veo.
*/

import type { VideoGenerator, VideoJob, VideoJobState, VideoRequest } from "../video/contract.js";
import { ProviderError, classifyProviderError } from "../shared/errors.js";
import { providerRequest } from "../shared/http.js";
import { resolveVideoModel } from "../video/models.js";
import { GeminiProvider, type VeoModel } from "./GeminiProvider.js";

export class VeoGenerator implements VideoGenerator {
readonly id = "veo";
readonly imageInput = "data-uri" as const;
private gemini = new GeminiProvider();
private apiKey?: string;

async initialize(config: { apiKey?: string }): Promise<void> {
this.apiKey = config.apiKey;
await this.gemini.initialize({ apiKey: config.apiKey });
}

async submitVideo(request: VideoRequest): Promise<VideoJob> {
if (request.from?.kind === "edit") {
throw new ProviderError({ kind: "unsupported", provider: this.id, message: "Veo cannot edit an earlier video; it can extend one." });
}
// Extension defaults to Veo 3.1 (not Fast), as the extend command always has.
const model = resolveVideoModel(this.id, request.model ?? (request.from ? "3.1" : undefined)).id as VeoModel;
const result = request.from
? await this.gemini.extendVideo(request.from.job.id, request.prompt, {
duration: (request.durationSec ?? 8) as 4 | 6 | 8,
model,
})
: await this.gemini.generateVideo(request.prompt, {
prompt: request.prompt,
model,
duration: request.durationSec,
aspectRatio: request.aspectRatio,
referenceImage: request.image,
lastFrame: request.lastFrame,
negativePrompt: request.negativePrompt,
resolution: request.resolution,
});
if (result.status === "failed" || !result.id) {
throw classifyProviderError({ provider: this.id, message: result.error ?? "Veo generation failed" });
}
return { provider: this.id, id: result.id, model, submittedAt: new Date().toISOString() };
}

async getVideoJob(job: VideoJob): Promise<VideoJobState> {
const result = await this.gemini.getGenerationStatus(job.id);
return {
status: result.status,
progress: result.progress,
videoUrl: result.videoUrl,
...(result.status === "failed"
? { error: classifyProviderError({ provider: this.id, message: result.error ?? "Veo generation failed" }).toJSON() }
: {}),
};
}

async downloadVideo(_job: VideoJob, state: VideoJobState): Promise<Uint8Array> {
if (!state.videoUrl || !this.apiKey) {
throw new ProviderError({ kind: "not-found", provider: this.id, message: "Veo job has no video to download." });
}
const response = await providerRequest(this.id, state.videoUrl, { headers: { "x-goog-api-key": this.apiKey } });
return new Uint8Array(await response.arrayBuffer());
}
}
1 change: 1 addition & 0 deletions packages/ai-providers/src/grok/GrokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ interface GrokStatusResponse {
*/
export class GrokProvider implements AIProvider, VideoGenerator {
id = "grok";
readonly imageInput = "either" as const;
name = "xAI Grok Imagine";
description = "AI video generation with Grok Imagine (native audio, 1-15 sec)";
capabilities: AICapability[] = ["text-to-video", "image-to-video", "text-to-image", "image-editing"];
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export {
geminiProvider,
OmniProvider,
omniProvider,
VeoGenerator,
isGeminiTextModelAlias,
resolveGeminiTextModel,
resolveVeoModel,
Expand All @@ -73,6 +74,7 @@ export {
type ModelStatus,
} from "./catalog/index.js";
export * from "./video/index.js";
export { VIDEO_GENERATOR_PROVIDERS, createVideoGenerator, type VideoGeneratorProvider } from "./video/registry.js";
export { OpenAIProvider, openaiProvider } from "./openai/index.js";
export { ClaudeProvider, claudeProvider } from "./claude/index.js";
export type { MotionOptions, MotionResult, RemotionComponent, StoryboardSegment } from "./claude/index.js";
Expand Down
14 changes: 13 additions & 1 deletion packages/ai-providers/src/kling/KlingProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export class KlingProvider implements AIProvider, VideoGenerator {
capabilities: AICapability[] = ["text-to-video", "image-to-video", "video-extend"];
iconUrl = "/icons/kling.svg";
isAvailable = true;
/** Kling's legacy endpoints take image URLs only. */
readonly imageInput = "url" as const;

private apiKey?: string;
private accessKey?: string;
Expand Down Expand Up @@ -224,7 +226,17 @@ export class KlingProvider implements AIProvider, VideoGenerator {
}

async getVideoJob(job: VideoJob): Promise<VideoJobState> {
const type = (job.meta?.type as KlingTaskType | undefined) ?? "text2video";
const type = job.meta?.type as KlingTaskType | undefined;
if (!type) {
// A bare task ID (typed in by a user) does not say which endpoint
// created it; Kling answers "not found" on the wrong one.
try {
return await this.getVideoJob({ ...job, meta: { ...job.meta, type: "text2video" } });
} catch (error) {
if (!(isProviderError(error) && error.kind === "not-found")) throw error;
return this.getVideoJob({ ...job, meta: { ...job.meta, type: "image2video" } });
}
}
const response = await providerRequest(this.id, `${this.baseUrl}/videos/${type}/${job.id}`, {
headers: this.headers(this.token()),
}, this.requestOptions());
Expand Down
1 change: 1 addition & 0 deletions packages/ai-providers/src/runway/RunwayProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export class RunwayProvider implements AIProvider, VideoGenerator {
capabilities: AICapability[] = ["text-to-video", "image-to-video"];
iconUrl = "/icons/runway.svg";
isAvailable = true;
readonly imageInput = "either" as const;

private static readonly API_VERSION = "2024-11-06";
private apiKey?: string;
Expand Down
5 changes: 5 additions & 0 deletions packages/ai-providers/src/video/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,14 @@ export interface VideoJobState {
error?: ProviderErrorInfo;
}

/** How a provider takes an input image: a public URL, an inline data URI, or either. */
export type ImageInput = "url" | "data-uri" | "either";

export interface VideoGenerator {
/** Provider registry id; matches `VideoJob.provider`. */
readonly id: string;
/** How `VideoRequest.image` must be given. Callers upload or inline local files to match. */
readonly imageInput: ImageInput;
/** Start a generation. Throws `ProviderError` when the provider refuses it. */
submitVideo(request: VideoRequest): Promise<VideoJob>;
/** Read a job's current state. Throws `ProviderError` only when the state cannot be read. */
Expand Down
55 changes: 55 additions & 0 deletions packages/ai-providers/src/video/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { FakeProviderNetwork, jsonResponse } from "../testing/fake-provider-network.js";
import { VIDEO_GENERATOR_PROVIDERS, createVideoGenerator } from "./registry.js";

describe("createVideoGenerator", () => {
it("returns an initialized generator for every video provider", async () => {
for (const provider of VIDEO_GENERATOR_PROVIDERS) {
const generator = await createVideoGenerator(provider, "test-key");
expect(generator.id).toBe(provider);
expect(["url", "data-uri", "either"]).toContain(generator.imageInput);
}
});

it("accepts fal as the old name for seedance and rejects unknown providers", async () => {
expect((await createVideoGenerator("fal", "k")).id).toBe("seedance");
await expect(createVideoGenerator("sora", "k")).rejects.toMatchObject({ kind: "invalid-request" });
});
});

describe("job lookups from a bare task ID", () => {
let net: FakeProviderNetwork;

beforeEach(() => {
net = new FakeProviderNetwork();
net.install();
});

afterEach(() => net.uninstall());

it("Kling tries image-to-video when text-to-video does not know the task", async () => {
net
.on("GET", "api.klingai.com", /\/text2video\/t-9$/, () => jsonResponse({ code: 1203, message: "task not found" }, 404))
.on("GET", "api.klingai.com", /\/image2video\/t-9$/, () =>
jsonResponse({ code: 0, message: "ok", request_id: "r", data: { task_id: "t-9", task_status: "processing" } })
);
const kling = await createVideoGenerator("kling", "k");

const state = await kling.getVideoJob({ provider: "kling", id: "t-9", model: "kling-v3", submittedAt: "" });

expect(state.status).toBe("processing");
expect(net.requests.map((r) => r.path)).toEqual(["/v1/videos/text2video/t-9", "/v1/videos/image2video/t-9"]);
});

it("Seedance polls a bare request ID on its model's app", async () => {
net.on("GET", "queue.fal.run", /\/bytedance\/seedance-2.0\/requests\/r-9\/status$/, () =>
jsonResponse({ status: "IN_PROGRESS", request_id: "r-9" })
);
const seedance = await createVideoGenerator("seedance", "k");

const state = await seedance.getVideoJob({ provider: "seedance", id: "r-9", model: "seedance-2.0", submittedAt: "" });

expect(state.status).toBe("processing");
});
});
44 changes: 44 additions & 0 deletions packages/ai-providers/src/video/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* One way to get a video provider by its registry id, so callers never
* name a provider class or branch on provider names.
*/

import { FalProvider } from "../fal/FalProvider.js";
import { OmniProvider } from "../gemini/gemini-omni.js";
import { VeoGenerator } from "../gemini/veo-generator.js";
import { GrokProvider } from "../grok/GrokProvider.js";
import { KlingProvider } from "../kling/KlingProvider.js";
import { RunwayProvider } from "../runway/RunwayProvider.js";
import { ProviderError } from "../shared/errors.js";
import type { VideoGenerator } from "./contract.js";

export const VIDEO_GENERATOR_PROVIDERS = ["seedance", "grok", "kling", "runway", "omni", "veo"] as const;
export type VideoGeneratorProvider = (typeof VIDEO_GENERATOR_PROVIDERS)[number];

/** A ready video generator for `provider` (`fal` is the old name for `seedance`). */
export async function createVideoGenerator(provider: string, apiKey: string): Promise<VideoGenerator> {
const id = provider === "fal" ? "seedance" : provider;
const generator =
id === "seedance"
? new FalProvider()
: id === "grok"
? new GrokProvider()
: id === "kling"
? new KlingProvider()
: id === "runway"
? new RunwayProvider()
: id === "omni"
? new OmniProvider()
: id === "veo"
? new VeoGenerator()
: undefined;
if (!generator) {
throw new ProviderError({
kind: "invalid-request",
provider: id,
message: `Unknown video provider "${provider}". Available: ${VIDEO_GENERATOR_PROVIDERS.join(", ")}.`,
});
}
await generator.initialize({ apiKey });
return generator;
}
1 change: 1 addition & 0 deletions packages/ai-providers/src/video/wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const job: VideoJob = { provider: "test", id: "j1", model: "m", submittedAt: "20
function generator(states: Array<VideoJobState | Error>): VideoGenerator {
return {
id: "test",
imageInput: "either",
submitVideo: vi.fn(),
downloadVideo: vi.fn(),
getVideoJob: vi.fn(async () => {
Expand Down
Loading
Loading