feat(ai): refresh txt2vid catalogs, retire Sora, default to Veo 3.1 Lite (#3828)

OpenAI shuts the Sora Videos API down on 2026-09-24 and sora-2 was the
default txt2vid model, so the default moves to Veo 3.1 Lite on Gemini
and the OpenAI video provider goes. While there, the video catalogs are
brought in line with what each vendor serves today, the request options
are unified across providers, and the txt2vid docs are rewritten.

- driver: default provider gemini-video-generation with
  veo-3.1-lite-generate-preview; a request under the generic `ai-video`
  driver name lands on the default instead of the first-registered
  provider; `WIDTHxHEIGHT` sizes map onto tier catalogs by the shorter
  side and fill width/height
- openai video provider, the `openai-video-generation` alias, its
  config template and migration entries, and Together's openai/sora-2*
  rows removed
- gemini: Veo 3.1 Fast rates 10/12/30 cents per second for
  720p/1080p/4K, Veo 3.1 Lite accepts reference images, URL image
  inputs are fetched server-side through the SSRF-guarded fetch
- together: drop nine models retired upstream, add eighteen from the
  live listing; per-second models are estimated from the catalog rate,
  clamped to remaining credit and billed at the cost Together reports
  on the job; tier-sized models take resolution/ratio;
  input_reference/last_frame map onto keyframes; generate_audio is
  forwarded
- byteplus: Seedance 2.5 (dreamina-seedance-2-5-260628) with per-model
  reference-image caps
- util/imageInput: string-level image helpers shared by the image and
  video drivers; ai-image/inputImage re-exports them unchanged
- puter.js types: provider and generate_audio options; docs: txt2vid
  page rewritten with per-provider model tables, unified options and
  four new playground examples

Known follow-up: Veo returns a key-protected Google file URL, so the
default clip cannot be played directly by a browser until the provider
fetches it server-side.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
404oops
2026-09-09 14:56:17 -07:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c8f4906af4
commit 6146ab9dd3
28 changed files with 1912 additions and 1560 deletions
-1
View File
@@ -427,7 +427,6 @@
"xai-image-generation": { "apiKey": "" },
// ─ Video generation ─
"openai-video-generation": { "apiKey": "" },
"together-video-generation": { "apiKey": "" },
"gemini-video-generation": { "apiKey": "" },
+12 -86
View File
@@ -20,50 +20,23 @@
/**
* Shared helpers for `input_images` (image-to-image) handling across image
* providers. `input_images` is the canonical, cross-provider field; an entry
* may be a public URL, a data-URI, or raw base64. Providers whose upstream API
* needs base64 use these helpers to normalize URLs server-side (via the
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
* xAI) pass them through untouched.
* may be a public URL, a data-URI, or raw base64. The string-level helpers live
* in `drivers/util/imageInput.ts` so video providers share them; this module
* re-exports them and adds the `IGenerateParams`-shaped validation.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { secureFetch } from '../../util/secureHttp.js';
import { assertInputImageString } from '../util/imageInput.js';
import type { IGenerateParams } from './types.js';
export function isHttpUrl(s: unknown): boolean {
return (
typeof s === 'string' &&
(s.startsWith('http://') || s.startsWith('https://'))
);
}
/**
* Normalize an input-image string for providers that accept URLs natively:
* http(s) URLs and data-URIs pass through untouched, raw base64 is wrapped with
* `mimeHint` (default image/png).
*/
export function toUrlOrDataUri(img: string, mimeHint?: string): string {
assertInputImageString(img, 'input image');
return isHttpUrl(img) || img.startsWith('data:')
? img
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
/**
* An input image is a URL, a data-URI or raw base64 — always a string. The
* field comes straight off the driver call, so the type has to be checked
* before the helpers below reach for `.startsWith`.
*/
export function assertInputImageString(img: unknown, label: string): string {
if (typeof img !== 'string') {
throw new HttpError(
400,
`${label}: each input image must be a URL, data-URI, or base64 string.`,
{ legacyCode: 'bad_request' },
);
}
return img;
}
export {
assertInputImageString,
fetchImageAsBase64,
isHttpUrl,
parseDataUri,
toBase64DataUri,
toUrlOrDataUri,
} from '../util/imageInput.js';
/**
* Validate `input_image` / `input_images` once, where the driver call arrives.
@@ -110,50 +83,3 @@ export function resolveSingleInputImage(
? undefined
: assertInputImageString(chosen, providerLabel);
}
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
export function parseDataUri(
s: string,
): { base64: string; mime: string } | null {
const m = DATA_URI_PATTERN.exec(s);
if (!m) return null;
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
}
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
export async function fetchImageAsBase64(
url: string,
): Promise<{ base64: string; mime: string }> {
const res = await secureFetch(url);
if (!res.ok) {
throw new HttpError(
400,
`Failed to fetch input image (status ${res.status})`,
{ legacyCode: 'bad_request' },
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
return { base64: buffer.toString('base64'), mime };
}
/**
* Normalize any input-image string to a base64 data-URI: • http(s) URL →
* fetched via secureFetch • data-URI → returned as-is • raw base64 → wrapped
* with `mimeHint` (default image/png)
*/
export async function toBase64DataUri(
img: string,
mimeHint?: string,
): Promise<string> {
assertInputImageString(img, 'input image');
if (img.startsWith('data:')) return img;
if (isHttpUrl(img)) {
const { base64, mime } = await fetchImageAsBase64(img);
return `data:${mime};base64,${base64}`;
}
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
@@ -46,43 +46,17 @@ import { PuterServer } from '../../server.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { setupTestServer } from '../../testUtil.js';
import { GEMINI_VIDEO_GENERATION_MODELS } from './providers/gemini/models.js';
import { OPENAI_VIDEO_MODELS } from './providers/openai/models.js';
import { TOGETHER_VIDEO_GENERATION_MODELS } from './providers/together/models.js';
import type { VideoGenerationDriver } from './VideoGenerationDriver.js';
const DEFAULT_MODEL = 'veo-3.1-lite-generate-preview';
// ── SDK mocks ──────────────────────────────────────────────────────
//
// These boot during PuterServer.start() since each provider's
// constructor instantiates its SDK. The driver-level tests only care
// about which provider the driver dispatched to.
const {
openaiVideosCreateMock,
openaiVideosRetrieveMock,
openaiVideosDownloadMock,
} = vi.hoisted(() => ({
openaiVideosCreateMock: vi.fn(),
openaiVideosRetrieveMock: vi.fn(),
openaiVideosDownloadMock: vi.fn(),
}));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
) {
this.videos = {
create: openaiVideosCreateMock,
retrieve: openaiVideosRetrieveMock,
downloadContent: openaiVideosDownloadMock,
};
this.chat = { completions: { create: vi.fn() } };
this.images = { generate: vi.fn() };
this.audio = { speech: { create: vi.fn() } };
});
(OpenAICtor as unknown as { OpenAI: unknown }).OpenAI = OpenAICtor;
return { OpenAI: OpenAICtor, default: OpenAICtor };
});
const { geminiGenerateVideosMock } = vi.hoisted(() => ({
geminiGenerateVideosMock: vi.fn(),
}));
@@ -139,7 +113,6 @@ let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
beforeAll(async () => {
server = await setupTestServer({
providers: {
'openai-video-generation': { apiKey: 'oai-key' },
'together-video-generation': { apiKey: 'tg-key' },
'gemini-video-generation': { apiKey: 'gem-key' },
},
@@ -152,9 +125,6 @@ afterAll(async () => {
});
beforeEach(() => {
openaiVideosCreateMock.mockReset();
openaiVideosRetrieveMock.mockReset();
openaiVideosDownloadMock.mockReset();
geminiGenerateVideosMock.mockReset();
togetherVideosCreateMock.mockReset();
togetherVideosRetrieveMock.mockReset();
@@ -176,26 +146,28 @@ const withActor = <T>(fn: () => T | Promise<T>): Promise<T> =>
const withDriverName = <T>(driverName: string, fn: () => T | Promise<T>) =>
Promise.resolve(runWithContext({ actor: SYSTEM_ACTOR, driverName }, fn));
const openaiCompletedJob = () => ({
id: 'oai-job',
status: 'completed' as const,
size: '720x1280',
seconds: '4',
// Terminal Veo operation; the driver's polling loop reads `done` first.
const geminiCompletedOperation = (
video: Record<string, unknown> = { uri: 'https://gemini/out.mp4' },
) => ({
done: true,
response: { generatedVideos: [{ video }] },
});
const openaiDownload = () => ({
headers: new Headers({ 'content-type': 'video/mp4' }),
body: null,
arrayBuffer: async () =>
new Uint8Array(Buffer.from('video-bytes')).buffer as ArrayBuffer,
});
const geminiSent = (call = 0) =>
geminiGenerateVideosMock.mock.calls[call]![0] as {
model: string;
prompt: string;
config: Record<string, unknown>;
puter_output_path?: unknown;
};
// ── Authentication ──────────────────────────────────────────────────
describe('VideoGenerationDriver.generate authentication', () => {
it('throws 401 when no actor is on the request context', async () => {
await expect(
driver.generate({ prompt: 'hi', model: 'sora-2' } as never),
driver.generate({ prompt: 'hi', model: DEFAULT_MODEL } as never),
).rejects.toMatchObject({ statusCode: 401 });
});
});
@@ -218,14 +190,13 @@ describe('VideoGenerationDriver.generate argument validation', () => {
// ── Catalog & list ──────────────────────────────────────────────────
// Providers hand these catalogs to the driver by module-level reference
// (OpenAI's directly; Gemini's and Together's via per-call copies), so
// #buildModelMap must never write through to them: an in-place id
// normalization or puterId append would accumulate across map builds. Cloned
// at import time, before beforeAll boots the server that builds the map.
// (Same regression as in ChatCompletionDriver.test.ts.)
// (via per-call copies), so #buildModelMap must never write through to
// them: an in-place id normalization or puterId append would accumulate
// across map builds. Cloned at import time, before beforeAll boots the
// server that builds the map. (Same regression as in
// ChatCompletionDriver.test.ts.)
const pristineCatalogs = structuredClone({
GEMINI_VIDEO_GENERATION_MODELS,
OPENAI_VIDEO_MODELS,
TOGETHER_VIDEO_GENERATION_MODELS,
});
@@ -233,7 +204,6 @@ describe('VideoGenerationDriver catalog', () => {
it('does not mutate the catalog objects providers hand back', () => {
expect({
GEMINI_VIDEO_GENERATION_MODELS,
OPENAI_VIDEO_MODELS,
TOGETHER_VIDEO_GENERATION_MODELS,
}).toEqual(pristineCatalogs);
});
@@ -242,14 +212,16 @@ describe('VideoGenerationDriver catalog', () => {
const all = await driver.models();
const ids = all.map((m) => m.id);
// Sentinel ids from each provider.
expect(ids).toContain('sora-2'); // OpenAI
expect(ids).toContain('veo-3.1-generate-preview'); // Gemini
// Together IDs are lowercased togetherai:org/model strings.
expect(ids).toContain('togetherai:minimax/video-01-director');
expect(ids.some((id) => id.includes('sora'))).toBe(false);
// Sort assertion: same-provider entries should be alphabetical.
const openaiEntries = all.filter((m) => m.provider === 'openai-video-generation');
const openaiIds = openaiEntries.map((m) => m.id);
expect(openaiIds).toEqual([...openaiIds].sort());
const geminiEntries = all.filter(
(m) => m.provider === 'gemini-video-generation',
);
const geminiIds = geminiEntries.map((m) => m.id);
expect(geminiIds).toEqual([...geminiIds].sort());
});
it('list() returns ids sorted', async () => {
@@ -263,13 +235,15 @@ describe('VideoGenerationDriver catalog', () => {
costValue: number;
source: string;
}>;
// sora-2 has a per-second line — must surface in reportedCosts.
const sora2PerSec = reported.find(
(r) => r.usageType === 'openai-video-generation:sora-2:per-second',
// The default model has a per-second line — must surface in reportedCosts.
const litePerSec = reported.find(
(r) =>
r.usageType ===
`gemini-video-generation:${DEFAULT_MODEL}:per-second`,
);
expect(sora2PerSec).toBeDefined();
expect(sora2PerSec?.source).toBe(
'driver:aiVideo/openai-video-generation',
expect(litePerSec).toBeDefined();
expect(litePerSec?.source).toBe(
'driver:aiVideo/gemini-video-generation',
);
});
});
@@ -277,28 +251,10 @@ describe('VideoGenerationDriver catalog', () => {
// ── Provider routing ────────────────────────────────────────────────
describe('VideoGenerationDriver.generate provider routing', () => {
it('routes a known sora-2 id to the OpenAI video provider', async () => {
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
await withActor(() =>
driver.generate({ prompt: 'hi', model: 'sora-2' } as never),
);
expect(openaiVideosCreateMock).toHaveBeenCalledTimes(1);
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
it('routes a known veo-3.1-generate-preview id to the Gemini provider', async () => {
geminiGenerateVideosMock.mockResolvedValueOnce({
done: true,
response: {
generatedVideos: [
{ video: { uri: 'https://gemini/out.mp4' } },
],
},
});
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withActor(() =>
driver.generate({
@@ -308,7 +264,8 @@ describe('VideoGenerationDriver.generate provider routing', () => {
);
expect(geminiGenerateVideosMock).toHaveBeenCalledTimes(1);
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiSent().model).toBe('veo-3.1-generate-preview');
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
it('routes a known togetherai:minimax/video-01-director id to the Together provider', async () => {
@@ -327,29 +284,46 @@ describe('VideoGenerationDriver.generate provider routing', () => {
);
expect(togetherVideosCreateMock).toHaveBeenCalledTimes(1);
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
it('lowercases model lookups so case variants resolve (SORA-2 → sora-2)', async () => {
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
await withActor(() =>
driver.generate({ prompt: 'hi', model: 'SORA-2' } as never),
it('lowercases model lookups so case variants resolve (VEO-3.1-LITE → veo-3.1-lite)', async () => {
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
expect(openaiVideosCreateMock).toHaveBeenCalledTimes(1);
await withActor(() =>
driver.generate({ prompt: 'hi', model: 'VEO-3.1-LITE' } as never),
);
expect(geminiGenerateVideosMock).toHaveBeenCalledTimes(1);
expect(geminiSent().model).toBe(DEFAULT_MODEL);
});
it('defaults to openai-video-generation when no model or provider hint is supplied', async () => {
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
it('defaults to Veo 3.1 Lite on Gemini when no model or provider hint is supplied', async () => {
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withActor(() =>
await withActor(() => driver.generate({ prompt: 'hi' } as never));
expect(geminiGenerateVideosMock).toHaveBeenCalledTimes(1);
expect(geminiSent().model).toBe(DEFAULT_MODEL);
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
it('still defaults to Veo 3.1 Lite when Context.driverName is the generic ai-video alias', async () => {
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withDriverName('ai-video', () =>
driver.generate({ prompt: 'hi' } as never),
);
expect(openaiVideosCreateMock).toHaveBeenCalledTimes(1);
expect(geminiGenerateVideosMock).toHaveBeenCalledTimes(1);
expect(geminiSent().model).toBe(DEFAULT_MODEL);
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
it('falls through to the requested provider via Context.driverName when args.provider is not supplied', async () => {
@@ -375,53 +349,55 @@ describe('VideoGenerationDriver.generate provider routing', () => {
describe('VideoGenerationDriver.generate parameter normalisation', () => {
it('snaps invalid seconds to the first allowed value for the resolved model', async () => {
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
seconds: 999, // not in [4, 8, 12]
model: DEFAULT_MODEL,
seconds: 999, // not in [4, 6, 8]
} as never),
);
const sent = openaiVideosCreateMock.mock.calls[0]![0];
// Sora-2 first allowed second is 4 (snapped from 999).
expect(sent.seconds).toBe('4');
// Veo's first allowed second is 4 (snapped from 999).
expect(geminiSent().config.durationSeconds).toBe(4);
});
it('snaps invalid resolution to the first allowed dimension for the resolved model', async () => {
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
size: '99x99',
} as never),
);
const sent = openaiVideosCreateMock.mock.calls[0]![0];
// Sora-2 first dimension is 720x1280.
expect(sent.size).toBe('720x1280');
// Veo's first dimension is 1280x720 → 16:9 at 720p.
const { config } = geminiSent();
expect(config.aspectRatio).toBe('16:9');
expect(config.resolution).toBe('720p');
});
it('coerces a string seconds value to a number before snapping', async () => {
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
seconds: '8',
} as never),
);
const sent = openaiVideosCreateMock.mock.calls[0]![0];
expect(sent.seconds).toBe('8');
expect(geminiSent().config.durationSeconds).toBe(8);
});
});
@@ -433,13 +409,13 @@ describe('VideoGenerationDriver.generate error mapping', () => {
withActor(() =>
driver.generate({
prompt: '',
model: 'sora-2',
model: DEFAULT_MODEL,
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
// Provider should not be called when validation lives at provider level.
// The error is thrown by the provider; ensure no upstream call leaked.
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
it('does not meter when the dispatched provider throws an SDK error', async () => {
@@ -447,13 +423,15 @@ describe('VideoGenerationDriver.generate error mapping', () => {
server.services.metering,
'incrementUsage',
);
openaiVideosCreateMock.mockRejectedValueOnce(new Error('upstream blew up'));
geminiGenerateVideosMock.mockRejectedValueOnce(
new Error('upstream blew up'),
);
await expect(
withActor(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
} as never),
),
).rejects.toThrow('upstream blew up');
@@ -469,17 +447,18 @@ describe('VideoGenerationDriver metering propagation', () => {
server.services.metering,
'incrementUsage',
);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
await withActor(() =>
driver.generate({ prompt: 'hi', model: 'sora-2' } as never),
driver.generate({ prompt: 'hi', model: DEFAULT_MODEL } as never),
);
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType] = incrementUsageSpy.mock.calls[0]!;
// OpenAIVideoProvider meters under the openai:<model>:<tier> shape.
expect(usageType).toMatch(/^openai:sora-2:/);
// GeminiVideoProvider meters under the gemini:<model>[:tier] shape.
expect(usageType).toMatch(/^gemini:veo-3\.1-lite-generate-preview/);
});
});
@@ -498,13 +477,13 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
it('throws 400 when puter_output_path parent is root (e.g. /video.mp4)', async () => {
@@ -512,13 +491,13 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/video.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
it('throws 403 when ACL denies write access', async () => {
@@ -529,13 +508,13 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
it('ACL check runs BEFORE provider.generate so credits are not wasted', async () => {
@@ -545,16 +524,16 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
callOrder.push('acl');
return false;
});
openaiVideosCreateMock.mockImplementation(async () => {
geminiGenerateVideosMock.mockImplementation(async () => {
callOrder.push('provider');
return openaiCompletedJob();
return geminiCompletedOperation();
});
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/testuser/dir/clip.mp4',
} as never),
),
@@ -570,13 +549,20 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
secureFetchMock.mockResolvedValueOnce(
new Response(Buffer.from('fake-mp4'), {
status: 200,
headers: { 'content-type': 'video/mp4' },
}),
);
await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '~/videos/clip.mp4',
} as never),
);
@@ -629,24 +615,27 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
expect(meta.contentType).toBe('video/mp4');
});
it('writes stream result to FS and returns a new stream to caller', async () => {
it('decodes an inline data-URI result, writes it to FS and hands it back unchanged', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
const videoBytes = Buffer.from('video-bytes').toString('base64');
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation({ videoBytes, mimeType: 'video/mp4' }),
);
const result = await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
);
expect(secureFetchMock).not.toHaveBeenCalled();
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(userId).toBe(42);
@@ -655,14 +644,17 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
fileMetadata: {
path: string;
contentType: string;
size: number;
overwrite: boolean;
};
}
).fileMetadata;
expect(meta.path).toBe('/testuser/videos/clip.mp4');
expect(meta.contentType).toBe('video/mp4');
expect(meta.size).toBe(Buffer.byteLength('video-bytes'));
expect(meta.overwrite).toBe(true);
expect(result).toBeDefined();
expect(result).toBe(`data:video/mp4;base64,${videoBytes}`);
});
it('does not forward puter_output_path to the upstream provider call', async () => {
@@ -672,19 +664,25 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
geminiGenerateVideosMock.mockResolvedValueOnce(
geminiCompletedOperation(),
);
secureFetchMock.mockResolvedValueOnce(
new Response(Buffer.from('fake-mp4'), {
status: 200,
headers: { 'content-type': 'video/mp4' },
}),
);
await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/testuser/dir/clip.mp4',
} as never),
);
const sent = openaiVideosCreateMock.mock.calls[0]![0];
expect(sent.puter_output_path).toBeUndefined();
expect(geminiSent().puter_output_path).toBeUndefined();
});
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
@@ -696,13 +694,106 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
runWithContext({ actor: noIdActor }, () =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
model: DEFAULT_MODEL,
puter_output_path: '/noone/dir/clip.mp4',
} as never),
),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
expect(geminiGenerateVideosMock).not.toHaveBeenCalled();
});
});
// ── Size unification ───────────────────────────────────────────────
describe('VideoGenerationDriver.generate size unification', () => {
const completeTogetherJob = () => {
togetherVideosCreateMock.mockResolvedValueOnce({ id: 'tg-job' });
togetherVideosRetrieveMock.mockResolvedValueOnce({
id: 'tg-job',
status: 'completed',
outputs: { video_url: 'https://together/out.mp4' },
});
};
const togetherSent = () =>
togetherVideosCreateMock.mock.calls[0]![0] as Record<string, unknown>;
it('maps a WIDTHxHEIGHT size onto a tier plus aspect ratio for tier-based models', async () => {
completeTogetherJob();
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'togetherai:wan-ai/wan2.7-t2v',
size: '1920x1080',
} as never),
);
const sent = togetherSent();
expect(sent.resolution).toBe('1080P');
expect(sent.ratio).toBe('16:9');
expect('width' in sent).toBe(false);
});
it('picks the tier of the shorter side and drops the ratio when the model has none', async () => {
completeTogetherJob();
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'togetherai:bytedance/seedance-2.5',
size: '480x854',
} as never),
);
const sent = togetherSent();
expect(sent.resolution).toBe('480p');
expect('ratio' in sent).toBe(false);
});
it('fills width/height from a WIDTHxHEIGHT size for pixel-sized models', async () => {
completeTogetherJob();
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'togetherai:minimax/hailuo-02',
size: '1920x1080',
} as never),
);
const sent = togetherSent();
expect(sent.width).toBe(1920);
expect(sent.height).toBe(1080);
});
it('leaves width/height alone when the caller set them or passed no size', async () => {
completeTogetherJob();
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'togetherai:minimax/hailuo-02',
size: '1920x1080',
width: 1366,
height: 768,
} as never),
);
expect(togetherSent().width).toBe(1366);
expect(togetherSent().height).toBe(768);
completeTogetherJob();
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'togetherai:minimax/hailuo-02',
} as never),
);
const second = togetherVideosCreateMock.mock.calls[1]![0] as Record<
string,
unknown
>;
expect('width' in second).toBe(false);
expect('height' in second).toBe(false);
});
});
@@ -28,7 +28,6 @@ import { secureFetch } from '../../util/secureHttp.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { BytePlusVideoProvider } from './providers/byteplus/BytePlusVideoProvider.js';
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
import { OpenAIVideoProvider } from './providers/openai/OpenAIVideoProvider.js';
import { TogetherVideoProvider } from './providers/together/TogetherVideoProvider.js';
import type {
IGenerateVideoParams,
@@ -36,12 +35,39 @@ import type {
IVideoProvider,
} from './types.js';
const DEFAULT_PROVIDER = 'openai-video-generation';
const DEFAULT_PROVIDER = 'gemini-video-generation';
const isResolutionTier = (value: string): boolean => /^\d{3,4}p$/i.test(value);
const parsePixelSize = (
value: string | undefined,
): { width: number; height: number } | undefined => {
const match = value?.match(/^(\d+)\s*x\s*(\d+)$/i);
if (!match) return undefined;
const width = Number.parseInt(match[1], 10);
const height = Number.parseInt(match[2], 10);
return width > 0 && height > 0 ? { width, height } : undefined;
};
/** Resolution tier of the shorter side, e.g. 1920x1080 and 1080x1920 → 1080p. */
const tierForPixels = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const shortSide = Math.min(width, height);
if (shortSide <= 480) return '480p';
if (shortSide <= 720) return '720p';
if (shortSide <= 1080) return '1080p';
return '4k';
};
/**
* Driver implementing the `puter-video-generation` interface.
*
* Manages multiple upstream providers (OpenAI/Sora, Together, Gemini/Veo, ...)
* Manages multiple upstream providers (Gemini/Veo, Together, BytePlus/Seedance)
* and handles model resolution, provider routing, and parameter normalisation.
* Each provider is a plain `IVideoProvider` -- the driver instantiates them
* from config on boot.
@@ -55,9 +81,8 @@ export class VideoGenerationDriver extends PuterDriver {
// alias all provider ids here. `generate` falls back to
// `Context.driverName` when `args.provider` isn't supplied.
readonly driverAliases = [
'openai-video-generation',
'together-video-generation',
'gemini-video-generation',
'together-video-generation',
'byteplus-video-generation',
];
readonly isDefault = true;
@@ -158,19 +183,24 @@ export class VideoGenerationDriver extends PuterDriver {
throw new Error('no video generation providers configured');
}
// The generic `ai-video` driver name is not a provider, so requests
// that name no usable provider land on the default rather than on
// whichever provider happened to register first.
const fallbackProvider = configuredProviders.includes(DEFAULT_PROVIDER)
? DEFAULT_PROVIDER
: configuredProviders[0];
let intendedProvider =
args.provider ??
(Context.get('driverName') as string | undefined) ??
'';
if (!args.model && !intendedProvider) {
intendedProvider = configuredProviders.includes(DEFAULT_PROVIDER)
? DEFAULT_PROVIDER
: configuredProviders[0];
intendedProvider = fallbackProvider;
}
if (intendedProvider && !this.#providers[intendedProvider]) {
intendedProvider = configuredProviders[0];
intendedProvider = fallbackProvider;
}
if (!args.model && intendedProvider) {
@@ -216,24 +246,44 @@ export class VideoGenerationDriver extends PuterDriver {
if (model.dimensions?.length) {
const requestedResolution =
typeof args.size === 'string' && args.size.trim()
? args.size
? args.size.trim()
: typeof args.resolution === 'string' &&
args.resolution.trim()
? args.resolution
? args.resolution.trim()
: undefined;
const requestedPixels = parsePixelSize(requestedResolution);
// `WIDTHxHEIGHT` is the one size vocabulary callers can use with
// every provider. Tier-based catalogs ('720p') take the tier of
// the shorter side; width/height then carry the aspect ratio for
// providers that derive one, or the exact size for providers that
// take pixels.
const catalogIsTiers = model.dimensions.every(isResolutionTier);
const wanted =
catalogIsTiers && requestedPixels
? tierForPixels(requestedPixels)
: requestedResolution;
// Case-insensitive so '4K' matches a catalog entry spelled '4k';
// the matched catalog spelling (not the caller's) is forwarded.
const normalizedResolution =
(requestedResolution &&
(wanted &&
model.dimensions.find(
(d) =>
d.toLowerCase() ===
requestedResolution.toLowerCase(),
(d) => d.toLowerCase() === wanted.toLowerCase(),
)) ||
model.dimensions[0];
args.size = normalizedResolution;
args.resolution = normalizedResolution;
if (requestedPixels && args.width == null && args.height == null) {
const pixels = catalogIsTiers
? requestedPixels
: parsePixelSize(normalizedResolution);
if (pixels) {
args.width = pixels.width;
args.height = pixels.height;
}
}
}
const result = await provider.generate({
@@ -271,14 +321,13 @@ export class VideoGenerationDriver extends PuterDriver {
return undefined;
};
const openaiKey = readKey(
providers['openai-video-generation'],
providers['openai-completion'],
providers['openai'],
const geminiKey = readKey(
providers['gemini-video-generation'],
providers['gemini'],
);
if (openaiKey) {
this.#providers['openai-video-generation'] =
new OpenAIVideoProvider({ apiKey: openaiKey }, m);
if (geminiKey) {
this.#providers['gemini-video-generation'] =
new GeminiVideoProvider({ apiKey: geminiKey }, m);
}
const togetherKey = readKey(
@@ -290,15 +339,6 @@ export class VideoGenerationDriver extends PuterDriver {
new TogetherVideoProvider({ apiKey: togetherKey }, m);
}
const geminiKey = readKey(
providers['gemini-video-generation'],
providers['gemini'],
);
if (geminiKey) {
this.#providers['gemini-video-generation'] =
new GeminiVideoProvider({ apiKey: geminiKey }, m);
}
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
// selects the ModelArk region, same as the chat provider. Each field
// falls through independently so a partial video-specific block can't
+2 -2
View File
@@ -45,8 +45,8 @@ export interface ICapSecondsParams {
* Clamp a video's duration to what the actor's remaining credit actually buys.
*
* Video is the only AI modality where a single request can cost multiples of a
* whole monthly allowance (Sora 2 Pro at 1080p is $0.70/second — a 12s clip is
* $8.40), so an all-or-nothing affordability check leaves the entire request
* whole monthly allowance (Veo 3.1 at 4K is $0.60/second — an 8s clip is
* $4.80), so an all-or-nothing affordability check leaves the entire request
* cost as slop above the budget. This is the video analogue of the `max_tokens`
* clamp in `ChatCompletionDriver`: shorten the output to fit the wallet, and
* only reject outright when even the shortest supported clip is unaffordable.
@@ -520,3 +520,82 @@ describe('BytePlusVideoProvider.generate metering', () => {
).rejects.toMatchObject({ statusCode: 402 });
});
});
// -- Seedance 2.5 ----------------------------------------------------
describe('BytePlusVideoProvider.generate seedance 2.5', () => {
const refs = (n: number) =>
Array.from({ length: n }, (_, i) => `https://example.com/ref-${i}.png`);
it('resolves the short alias, accepts 1080p, sends audio and omits seed', async () => {
mockTaskFlow(succeededTask({ resolution: '1080p' }));
await withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-5',
prompt: 'hi',
resolution: '1080p',
seed: 7,
}),
);
const body = sentBody();
expect(body.model).toBe('dreamina-seedance-2-5-260628');
expect(body.resolution).toBe('1080p');
expect(body.duration).toBe(5);
expect(body.generate_audio).toBe(true);
expect(body.seed).toBeUndefined();
});
it('accepts up to 30 reference images on 2.5 while 2.0 stays capped at 9', async () => {
mockTaskFlow(succeededTask());
await withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-5',
prompt: 'hi',
reference_images: refs(30),
}),
);
const content = sentBody().content as Array<{ role?: string }>;
expect(content.filter((c) => c.role === 'reference_image')).toHaveLength(
30,
);
await expect(
withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-5',
prompt: 'hi',
reference_images: refs(31),
}),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-0',
prompt: 'hi',
reference_images: refs(10),
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('bills 1080p output at the 2.5 rate', async () => {
mockTaskFlow(succeededTask({ resolution: '1080p' }));
await withTestActor(() =>
makeProvider().generate({
model: 'seedance-2-5',
prompt: 'hi',
resolution: '1080p',
}),
);
const model = findModel('dreamina-seedance-2-5-260628');
const rate = model.costs!['video_tokens:1080p'];
expect(incrementUsageSpy).toHaveBeenCalledWith(
expect.anything(),
'byteplus-video-generation:dreamina-seedance-2-5-260628:video_tokens:1080p',
108_000,
108_000 * rate * 1_000_000,
);
});
});
@@ -34,8 +34,6 @@ const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
const DEFAULT_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3';
const DEFAULT_POLL_INTERVAL_MS = 5_000;
const DEFAULT_MODEL = 'dreamina-seedance-2-0-mini-260615';
// Seedance 2.0 multimodal reference accepts up to 9 reference images.
const MAX_REFERENCE_IMAGES = 9;
const ARK_RATIOS = ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
@@ -276,10 +274,10 @@ export class BytePlusVideoProvider extends VideoProvider {
{ legacyCode: 'bad_request' },
);
}
if (referenceImages!.length > MAX_REFERENCE_IMAGES) {
if (referenceImages!.length > spec.maxReferenceImages) {
throw new HttpError(
400,
`${modelId} accepts at most ${MAX_REFERENCE_IMAGES} reference image(s)`,
`${modelId} accepts at most ${spec.maxReferenceImages} reference image(s)`,
{ legacyCode: 'bad_request' },
);
}
@@ -36,8 +36,10 @@ export interface BytePlusVideoSpec {
supportsAudio: boolean;
/** Supports first+last frame image-to-video. */
supportsLastFrame: boolean;
/** Supports multimodal `reference_image` inputs (Seedance 2.0 series). */
/** Supports multimodal `reference_image` inputs (Seedance 2.x series). */
supportsReferenceImages: boolean;
/** Reference images accepted per request; 0 when unsupported. */
maxReferenceImages: number;
/** Supports the `seed` param (not the Seedance 2.0 series). */
supportsSeed: boolean;
}
@@ -66,11 +68,30 @@ const perMToken = (usd: number): number => (usd * 100) / 1_000_000;
// `default-duration-per-video` is the estimated cents for a 5s clip at the
// model's default resolution — it exists for cross-provider cost sorting and
// display, not billing.
//
// dreamina-seedance-2-5-260628 is priced on the pricing page but the API
// reference still lists its API access as "available soon", so it's
// deliberately absent here.
export const BYTEPLUS_VIDEO_GENERATION_MODELS: IVideoModel[] = [
{
id: 'dreamina-seedance-2-5-260628',
puterId: 'byteplus:byteplus/dreamina-seedance-2-5-260628',
aliases: [
'dreamina-seedance-2-5',
'byteplus/dreamina-seedance-2-5',
'seedance-2-5',
],
name: 'Dreamina Seedance 2.5',
costs_currency: 'usd-cents',
output_cost_key: 'default-duration-per-video',
costs: {
'video_tokens:480p': perMToken(10.7),
'video_tokens:720p': perMToken(10.7),
'video_tokens:1080p': perMToken(11.7),
'default-duration-per-video': 116,
},
durationSeconds: seconds(5, 4, 30),
dimensions: ['720p', '480p', '1080p'],
fps: FPS,
defaultUsageKey:
'byteplus-video-generation:dreamina-seedance-2-5-260628:video_tokens:720p',
},
{
id: 'dreamina-seedance-2-0-260128',
puterId: 'byteplus:byteplus/dreamina-seedance-2-0-260128',
@@ -205,12 +226,24 @@ const SEEDANCE_1_0_DIMS = {
};
export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
// Ark caps Seedance 2.5 at 50 multimodal references per request; 30 is
// the image share Together publishes for the same model.
'dreamina-seedance-2-5-260628': {
duration: { min: 4, max: 30, default: 5 },
dims: SEEDANCE_2_0_DIMS,
supportsAudio: true,
supportsLastFrame: true,
supportsReferenceImages: true,
maxReferenceImages: 30,
supportsSeed: false,
},
'dreamina-seedance-2-0-260128': {
duration: { min: 4, max: 15, default: 5 },
dims: SEEDANCE_2_0_DIMS,
supportsAudio: true,
supportsLastFrame: true,
supportsReferenceImages: true,
maxReferenceImages: 9,
supportsSeed: false,
},
'dreamina-seedance-2-0-fast-260128': {
@@ -219,6 +252,7 @@ export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
supportsAudio: true,
supportsLastFrame: true,
supportsReferenceImages: true,
maxReferenceImages: 9,
supportsSeed: false,
},
'dreamina-seedance-2-0-mini-260615': {
@@ -227,6 +261,7 @@ export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
supportsAudio: true,
supportsLastFrame: true,
supportsReferenceImages: true,
maxReferenceImages: 9,
supportsSeed: false,
},
'seedance-1-5-pro-251215': {
@@ -235,6 +270,7 @@ export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
supportsAudio: true,
supportsLastFrame: true,
supportsReferenceImages: false,
maxReferenceImages: 0,
supportsSeed: true,
},
'seedance-1-0-pro-250528': {
@@ -243,6 +279,7 @@ export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
supportsAudio: false,
supportsLastFrame: true,
supportsReferenceImages: false,
maxReferenceImages: 0,
supportsSeed: true,
},
'seedance-1-0-pro-fast-251015': {
@@ -251,6 +288,7 @@ export const BYTEPLUS_VIDEO_SPECS: Record<string, BytePlusVideoSpec> = {
supportsAudio: false,
supportsLastFrame: false,
supportsReferenceImages: false,
maxReferenceImages: 0,
supportsSeed: true,
},
};
@@ -77,6 +77,16 @@ vi.mock('@google/genai', () => {
return { GoogleGenAI };
});
// Veo needs inline bytes, so URL inputs go through the SSRF-guarded fetch.
const { secureFetchMock } = vi.hoisted(() => ({ secureFetchMock: vi.fn() }));
vi.mock('../../../../util/secureHttp.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../../../util/secureHttp.js')
>()),
secureFetch: secureFetchMock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -122,6 +132,7 @@ beforeEach(() => {
generateVideosMock.mockReset();
getVideosOperationMock.mockReset();
googleAICtor.mockReset();
secureFetchMock.mockReset();
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
remainingUsageSpy.mockResolvedValue(AMPLE_CREDIT);
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
@@ -154,10 +165,10 @@ describe('GeminiVideoProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('GeminiVideoProvider model catalog', () => {
it('getDefaultModel() returns the first catalog entry id', () => {
it('getDefaultModel() returns Veo 3.1 Lite', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe(
GEMINI_VIDEO_GENERATION_MODELS[0].id,
'veo-3.1-lite-generate-preview',
);
});
@@ -679,3 +690,146 @@ describe('GeminiVideoProvider.generate error paths', () => {
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// ── Catalog refresh ────────────────────────────────────────────────
describe('GeminiVideoProvider catalog refresh', () => {
it('passes reference_images on veo-3.1-lite', async () => {
const provider = makeProvider();
generateVideosMock.mockResolvedValueOnce(completedOperation());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'veo-3.1-lite-generate-preview',
reference_images: [
'data:image/png;base64,A',
'data:image/png;base64,B',
] as never,
}),
);
const sent = generateVideosMock.mock.calls[0]![0];
expect(sent.config.referenceImages).toHaveLength(2);
expect(sent.config.durationSeconds).toBe(8);
});
it('meters veo-3.1-fast 1080p under its own :1080p tier rate', async () => {
const provider = makeProvider();
generateVideosMock.mockResolvedValueOnce(completedOperation());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'veo-3.1-fast-generate-preview',
size: '1920x1080',
seconds: 8,
}),
);
const fast = GEMINI_VIDEO_GENERATION_MODELS.find(
(m) => m.id === 'veo-3.1-fast-generate-preview',
)!;
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('gemini:veo-3.1-fast-generate-preview:1080p');
expect(count).toBe(8);
expect(cost).toBe(
8 * Math.ceil(fast.costs!['per-second-1080p'] * 1_000_000),
);
});
});
// ── Unified image inputs ───────────────────────────────────────────
describe('GeminiVideoProvider.generate URL image inputs', () => {
const pngResponse = (bytes: string) =>
new Response(Buffer.from(bytes), {
status: 200,
headers: { 'content-type': 'image/png' },
});
it('fetches http(s) first/last frames server-side and inlines them', async () => {
const provider = makeProvider();
generateVideosMock.mockResolvedValueOnce(completedOperation());
// Keyed by URL: the provider inlines the last frame before the first.
secureFetchMock.mockImplementation(async (url: string) =>
pngResponse(url.includes('first') ? 'first-bytes' : 'last-bytes'),
);
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'veo-3.1-generate-preview',
input_reference: 'https://example.com/first.png',
last_frame: 'https://example.com/last.png',
}),
);
expect(secureFetchMock).toHaveBeenCalledWith(
'https://example.com/first.png',
);
expect(secureFetchMock).toHaveBeenCalledWith(
'https://example.com/last.png',
);
const sent = generateVideosMock.mock.calls[0]![0];
expect(sent.image).toEqual({
imageBytes: Buffer.from('first-bytes').toString('base64'),
mimeType: 'image/png',
});
expect(sent.config.lastFrame).toEqual({
imageBytes: Buffer.from('last-bytes').toString('base64'),
mimeType: 'image/png',
});
});
it('fetches URL reference_images and leaves data URIs untouched', async () => {
const provider = makeProvider();
generateVideosMock.mockResolvedValueOnce(completedOperation());
secureFetchMock.mockResolvedValueOnce(pngResponse('ref-bytes'));
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'veo-3.1-generate-preview',
reference_images: [
'https://example.com/ref.png',
'data:image/jpeg;base64,QUJD',
] as never,
}),
);
expect(secureFetchMock).toHaveBeenCalledTimes(1);
const sent = generateVideosMock.mock.calls[0]![0];
expect(sent.config.referenceImages).toEqual([
{
image: {
imageBytes: Buffer.from('ref-bytes').toString('base64'),
mimeType: 'image/png',
},
referenceType: 'asset',
},
{
image: { imageBytes: 'QUJD', mimeType: 'image/jpeg' },
referenceType: 'asset',
},
]);
});
it('surfaces a failed image fetch as 400 before calling Veo', async () => {
const provider = makeProvider();
secureFetchMock.mockResolvedValueOnce(
new Response('nope', { status: 404 }),
);
await expect(
withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'veo-3.1-generate-preview',
input_reference: 'https://example.com/missing.png',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(generateVideosMock).not.toHaveBeenCalled();
});
});
@@ -28,12 +28,15 @@ import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
import { capSecondsToRemainingCredits } from '../../creditCap.js';
import { isHttpUrl, toBase64DataUri } from '../../../util/imageInput.js';
import { VideoProvider } from '../VideoProvider.js';
import { pollUntilSettled, videoJobFailure } from '../polling.js';
import { GEMINI_VIDEO_GENERATION_MODELS, IGeminiVideoModel } from './models.js';
const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
const POLL_INTERVAL_MS = 10_000;
// Cheapest Veo tier; also what an unknown model id resolves to.
const DEFAULT_MODEL = 'veo-3.1-lite-generate-preview';
const DIMENSION_MAP: Record<
string,
@@ -61,7 +64,7 @@ export class GeminiVideoProvider extends VideoProvider {
}
getDefaultModel(): string {
return GEMINI_VIDEO_GENERATION_MODELS[0].id;
return DEFAULT_MODEL;
}
async models(): Promise<IVideoModel[]> {
@@ -182,10 +185,12 @@ export class GeminiVideoProvider extends VideoProvider {
typeof img === 'string' && img.trim().length > 0,
)
.slice(0, 3);
config.referenceImages = validImages.map((img: string) => ({
image: this.#parseImageInput(img),
referenceType: 'asset',
}));
config.referenceImages = await Promise.all(
validImages.map(async (img: string) => ({
image: await this.#inlineImage(img),
referenceType: 'asset',
})),
);
}
if (
@@ -193,7 +198,7 @@ export class GeminiVideoProvider extends VideoProvider {
typeof lastFrame === 'string' &&
lastFrame.trim()
) {
config.lastFrame = this.#parseImageInput(lastFrame);
config.lastFrame = await this.#inlineImage(lastFrame);
}
const generateParams: GenerateVideosParameters = {
@@ -204,7 +209,7 @@ export class GeminiVideoProvider extends VideoProvider {
// First frame (image-to-video)
if (hasFirstFrame && !hasRefImages) {
generateParams.image = this.#parseImageInput(
generateParams.image = await this.#inlineImage(
inputReference as string,
);
}
@@ -302,6 +307,18 @@ export class GeminiVideoProvider extends VideoProvider {
return op;
}
/**
* Veo only takes inline bytes, so a URL is fetched server-side (SSRF
* guarded) first; data URIs and raw base64 go straight to the parser.
*/
async #inlineImage(
input: string,
): Promise<{ imageBytes: string; mimeType: string }> {
return this.#parseImageInput(
isHttpUrl(input) ? await toBase64DataUri(input) : input,
);
}
#parseImageInput(input: string): { imageBytes: string; mimeType: string } {
if (input.startsWith('data:')) {
const commaIdx = input.indexOf(',');
@@ -325,7 +342,8 @@ export class GeminiVideoProvider extends VideoProvider {
return (
GEMINI_VIDEO_GENERATION_MODELS.find(
(m) => m.id === requestedModel,
) ?? GEMINI_VIDEO_GENERATION_MODELS[0]
) ??
GEMINI_VIDEO_GENERATION_MODELS.find((m) => m.id === DEFAULT_MODEL)!
);
}
@@ -52,7 +52,11 @@ export const GEMINI_VIDEO_GENERATION_MODELS: IGeminiVideoModel[] = [
id: 'veo-3.1-fast-generate-preview',
name: 'Veo 3.1 Fast',
costs_currency: 'usd-cents',
costs: { 'per-second': 15, 'per-second-4k': 35 },
costs: {
'per-second': 10,
'per-second-1080p': 12,
'per-second-4k': 30,
},
output_cost_key: 'per-second',
durationSeconds: [4, 6, 8],
dimensions: DIMENSIONS_WITH_4K,
@@ -73,6 +77,6 @@ export const GEMINI_VIDEO_GENERATION_MODELS: IGeminiVideoModel[] = [
aspectRatios: ['16:9', '9:16'],
resolutions: ['720p', '1080p'],
supportsImageInput: true,
supportsReferenceImages: false,
supportsReferenceImages: true,
},
];
@@ -1,639 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for OpenAIVideoProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs OpenAIVideoProvider directly against the live
* wired `MeteringService`. The OpenAI SDK is mocked at the module
* boundary that's the real network egress point. Covers parameter
* mapping (size/seconds normalization, input_reference forwarding),
* polling/long-running job state, sora-2-pro size tiering, error
* paths, and per-second cost reporting.
*/
import { Readable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { OpenAIVideoProvider } from './OpenAIVideoProvider.js';
import { OPENAI_VIDEO_MODELS } from './models.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const {
videosCreateMock,
videosRetrieveMock,
videosDownloadContentMock,
openAICtor,
} = vi.hoisted(() => ({
videosCreateMock: vi.fn(),
videosRetrieveMock: vi.fn(),
videosDownloadContentMock: vi.fn(),
openAICtor: vi.fn(),
}));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.videos = {
create: videosCreateMock,
retrieve: videosRetrieveMock,
downloadContent: videosDownloadContentMock,
};
// Sibling chat / image providers in the same boot.
this.chat = { completions: { create: vi.fn() } };
this.images = { generate: vi.fn() };
this.audio = { speech: { create: vi.fn() } };
});
(OpenAICtor as unknown as { OpenAI: unknown }).OpenAI = OpenAICtor;
return { OpenAI: OpenAICtor, default: OpenAICtor };
});
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
// Plenty of credit for every test that isn't specifically about the gate.
const AMPLE_CREDIT = 100_000_000_000;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () =>
new OpenAIVideoProvider({ apiKey: 'test-key' }, server.services.metering);
const sampleVideoBytes = () =>
new Uint8Array(Buffer.from('video-bytes')).buffer as ArrayBuffer;
const completedJob = (
overrides: Partial<{
id: string;
size: string;
seconds: string;
}> = {},
) => ({
id: 'job-1',
status: 'completed' as const,
size: '720x1280',
seconds: '4',
...overrides,
});
const downloadResponse = () => ({
headers: new Headers({ 'content-type': 'video/mp4' }),
body: null,
arrayBuffer: async () => sampleVideoBytes(),
});
beforeEach(() => {
videosCreateMock.mockReset();
videosRetrieveMock.mockReset();
videosDownloadContentMock.mockReset();
openAICtor.mockReset();
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
remainingUsageSpy.mockResolvedValue(AMPLE_CREDIT);
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
describe('OpenAIVideoProvider construction', () => {
it('constructs the OpenAI SDK with the configured api key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({ apiKey: 'test-key' });
});
it('throws when no apiKey is supplied', () => {
expect(
() =>
new OpenAIVideoProvider(
{ apiKey: '' },
server.services.metering,
),
).toThrow(/API key/i);
});
});
// ── Model catalog ───────────────────────────────────────────────────
describe('OpenAIVideoProvider model catalog', () => {
it('getDefaultModel() returns the first catalog entry id', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe(OPENAI_VIDEO_MODELS[0].id);
});
it('models() lists every catalog entry verbatim', async () => {
const provider = makeProvider();
expect(await provider.models()).toBe(OPENAI_VIDEO_MODELS);
});
});
// ── test_mode bypass ────────────────────────────────────────────────
describe('OpenAIVideoProvider.generate test_mode', () => {
it('returns the canned sample URL without hitting credits or the SDK', async () => {
const provider = makeProvider();
const result = await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
test_mode: true,
}),
);
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
expect(remainingUsageSpy).not.toHaveBeenCalled();
expect(videosCreateMock).not.toHaveBeenCalled();
});
});
// ── Argument validation ─────────────────────────────────────────────
describe('OpenAIVideoProvider.generate argument validation', () => {
it('throws 400 when prompt is missing or blank', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({ prompt: '', model: 'sora-2' }),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
withTestActor(() =>
provider.generate({ prompt: ' ', model: 'sora-2' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
it('throws 400 when model is unknown', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-fake' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
});
// ── Credit gate ─────────────────────────────────────────────────────
describe('OpenAIVideoProvider.generate credit gate', () => {
// sora-2 is 10 usd-cents/second, i.e. 10_000_000 micro-cents/second, and
// only accepts 4s / 8s / 12s clips.
const PER_SECOND = 10_000_000;
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
const provider = makeProvider();
remainingUsageSpy.mockResolvedValueOnce(0);
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toMatchObject({ statusCode: 402 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
it('throws 402 when credit falls one micro-cent short of the shortest clip', async () => {
const provider = makeProvider();
remainingUsageSpy.mockResolvedValueOnce(4 * PER_SECOND - 1);
await expect(
withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
seconds: 4,
}),
),
).rejects.toMatchObject({ statusCode: 402 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
it.each([
// remaining credit, requested seconds, seconds actually requested upstream
[4 * PER_SECOND, 12, '4'],
[7 * PER_SECOND, 12, '4'],
[10 * PER_SECOND, 12, '8'],
[12 * PER_SECOND, 12, '12'],
[4 * PER_SECOND, 8, '4'],
])(
'caps a %i micro-cent balance asking for %is down to %ss',
async (remaining, requested, expected) => {
const provider = makeProvider();
remainingUsageSpy.mockResolvedValueOnce(remaining);
videosCreateMock.mockResolvedValueOnce(
completedJob({ seconds: expected }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
seconds: requested,
}),
);
expect(videosCreateMock.mock.calls[0][0]).toMatchObject({
seconds: expected,
});
},
);
it('never meters more than the capped clip costs', async () => {
const provider = makeProvider();
// $1.00 — enough for 8s, not the 12s the caller asked for.
remainingUsageSpy.mockResolvedValueOnce(10 * PER_SECOND);
videosCreateMock.mockResolvedValueOnce(completedJob({ seconds: '8' }));
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2', seconds: 12 }),
);
const [, , count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(count).toBe(8);
expect(cost).toBe(8 * PER_SECOND);
expect(cost).toBeLessThanOrEqual(10 * PER_SECOND);
});
it('leaves an affordable request untouched', async () => {
const provider = makeProvider();
remainingUsageSpy.mockResolvedValueOnce(AMPLE_CREDIT);
videosCreateMock.mockResolvedValueOnce(completedJob({ seconds: '12' }));
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2', seconds: 12 }),
);
expect(videosCreateMock.mock.calls[0][0]).toMatchObject({
seconds: '12',
});
});
});
// ── Request shape & parameter mapping ──────────────────────────────
describe('OpenAIVideoProvider.generate parameter mapping', () => {
it('forwards model + prompt + normalised size and seconds defaults', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.model).toBe('sora-2');
expect(sent.prompt).toBe('hi');
// Default seconds = 4, default size = first dimension.
expect(sent.seconds).toBe('4');
expect(sent.size).toBe('720x1280');
});
it('snaps invalid seconds to the default (4) and invalid sizes to the first allowed', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
seconds: 7, // not in [4, 8, 12]
size: '99x99', // not in sora-2 dimensions
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.seconds).toBe('4');
expect(sent.size).toBe('720x1280');
});
it('honours valid seconds and size verbatim, normalising whitespace', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
seconds: '12',
size: '1280 x 720',
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.seconds).toBe('12');
expect(sent.size).toBe('1280x720');
});
it('forwards input_reference when supplied', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
input_reference: 'https://example/keyframe.png',
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.input_reference).toBe('https://example/keyframe.png');
});
});
// ── Polling / long-running job state ───────────────────────────────
describe('OpenAIVideoProvider.generate polling', () => {
it('returns the downloaded stream when the job completes on first poll', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
const result = (await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
)) as { stream: Readable; content_type: string };
expect(result.content_type).toBe('video/mp4');
expect(result.stream).toBeInstanceOf(Readable);
// Retrieve doesn't need to be called when status is already
// terminal on creation.
expect(videosRetrieveMock).not.toHaveBeenCalled();
});
it('polls past queued/in_progress states until completion', async () => {
vi.useFakeTimers();
try {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-poll',
status: 'queued',
size: '720x1280',
seconds: '4',
});
videosRetrieveMock
.mockResolvedValueOnce({
id: 'job-poll',
status: 'in_progress',
})
.mockResolvedValueOnce(completedJob({ id: 'job-poll' }));
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
const promise = withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
);
// Two poll intervals (5s each) → terminal state.
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(5_000);
await promise;
expect(videosRetrieveMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('gives up after the wait window as HttpError 504 upstream_timeout, without metering', async () => {
vi.useFakeTimers();
try {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-slow',
status: 'queued',
size: '720x1280',
seconds: '4',
});
videosRetrieveMock.mockResolvedValue({
id: 'job-slow',
status: 'in_progress',
});
const rejection = withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
).catch((e: unknown) => e);
// Five-minute wait window, polled every 5s.
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5_000);
expect(await rejection).toMatchObject({
statusCode: 504,
legacyCode: 'upstream_timeout',
message:
'Timed out waiting for Sora video generation to complete',
fields: { provider: 'openai' },
});
expect(videosDownloadContentMock).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('surfaces failed jobs as HttpError 400 upstream_failed (not a 500 page)', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-fail',
status: 'failed',
error: { message: 'content policy violation' },
size: '720x1280',
seconds: '4',
});
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_failed',
message: 'content policy violation',
});
expect(videosDownloadContentMock).not.toHaveBeenCalled();
});
});
// ── Sora-2-Pro size tiers ──────────────────────────────────────────
describe('OpenAIVideoProvider.generate sora-2-pro size tiers', () => {
it('meters the xxl tier when the resolved size is 1080x1920', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ size: '1080x1920', seconds: '4' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2-pro',
size: '1080x1920',
seconds: 4,
}),
);
const proModel = OPENAI_VIDEO_MODELS.find((m) => m.id === 'sora-2-pro')!;
const xxlPerSecond = proModel.costs!['per-second-xxl'];
const expectedCost = xxlPerSecond * 1_000_000 * 4;
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2-pro:xxl');
expect(count).toBe(4);
expect(cost).toBe(expectedCost);
});
it('meters the xl tier when the resolved size is 1024x1792', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ size: '1024x1792', seconds: '8' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2-pro',
size: '1024x1792',
seconds: 8,
}),
);
const [, usageType, count] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2-pro:xl');
expect(count).toBe(8);
});
it('meters the default tier on sora-2 across all dimensions', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ size: '1280x720', seconds: '8' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
size: '1280x720',
seconds: 8,
}),
);
const [, usageType, count] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2:default');
expect(count).toBe(8);
});
});
// ── Cost reporting & metering ───────────────────────────────────────
describe('OpenAIVideoProvider.generate metering', () => {
it('meters seconds × per-second cents × 1e6 under openai:<model>:default', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ seconds: '4' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2', seconds: 4 }),
);
const sora2 = OPENAI_VIDEO_MODELS.find((m) => m.id === 'sora-2')!;
const expectedCost = sora2.costs!['per-second'] * 1_000_000 * 4;
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2:default');
expect(count).toBe(4);
expect(cost).toBe(expectedCost);
});
it('does NOT meter when the job ends in failed state', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-fail',
status: 'failed',
error: { message: 'boom' },
size: '720x1280',
seconds: '4',
});
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toThrow();
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// ── Error paths ─────────────────────────────────────────────────────
describe('OpenAIVideoProvider.generate error paths', () => {
it('propagates SDK errors thrown from videos.create and does not meter', async () => {
const provider = makeProvider();
const apiError = new Error('upstream blew up');
videosCreateMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toBe(apiError);
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -1,307 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import OpenAI from 'openai';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
import { capSecondsToRemainingCredits } from '../../creditCap.js';
import { VideoProvider } from '../VideoProvider.js';
import { pollTimeoutError } from '../polling.js';
import { OPENAI_VIDEO_MODELS, OPENAI_VIDEO_ALLOWED_SECONDS } from './models.js';
import { Readable } from 'stream';
const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5_000;
const DEFAULT_DURATION_SECONDS = 4;
export class OpenAIVideoProvider extends VideoProvider {
#openai: OpenAI;
#meteringService: MeteringService;
constructor(config: { apiKey: string }, meteringService: MeteringService) {
super();
if (!config.apiKey) {
throw new Error('OpenAI video generation requires an API key');
}
this.#openai = new OpenAI({ apiKey: config.apiKey });
this.#meteringService = meteringService;
}
getDefaultModel(): string {
return OPENAI_VIDEO_MODELS[0].id;
}
async models(): Promise<IVideoModel[]> {
return OPENAI_VIDEO_MODELS;
}
async generate(params: IGenerateVideoParams): Promise<unknown> {
const {
prompt,
model: requestedModel,
duration,
seconds,
size,
resolution,
input_reference: inputReference,
test_mode: testMode,
} = params ?? {};
if (typeof prompt !== 'string' || !prompt.trim()) {
throw new HttpError(400, 'prompt must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const selectedModel = await this.#selectModel(requestedModel);
if (!selectedModel) {
throw new HttpError(400, `Unknown video model: ${requestedModel}`, {
legacyCode: 'bad_request',
});
}
if (testMode) {
return DEFAULT_TEST_VIDEO_URL;
}
const defaultSize = selectedModel.dimensions?.[0] ?? '720x1280';
const normalizedSize =
this.#normalizeSize(size ?? resolution, selectedModel) ??
defaultSize;
const normalizedSeconds =
this.#normalizeSeconds(seconds ?? duration) ??
String(DEFAULT_DURATION_SECONDS);
const sizeTier = this.#determineSizeTier(selectedModel, normalizedSize);
const costPerSecondCents = this.#getCostPerSecond(
selectedModel,
sizeTier,
);
if (!costPerSecondCents) {
throw new Error(
`No pricing configured for model ${selectedModel.id} at size ${normalizedSize}`,
);
}
const actor = Context.get('actor');
const costInMicroCents = costPerSecondCents * 1_000_000;
// Clamp the clip to what the actor's remaining credit buys rather than
// rejecting the whole request — a 12s Sora 2 Pro clip is $8.40, so
// all-or-nothing leaves the entire request cost as slop above budget.
const estimatedUnits = await capSecondsToRemainingCredits({
metering: this.#meteringService,
actor,
perSecondMicroCents: costInMicroCents,
requestedSeconds:
this.#parseSeconds(normalizedSeconds) ??
DEFAULT_DURATION_SECONDS,
allowedSeconds:
selectedModel.durationSeconds ?? OPENAI_VIDEO_ALLOWED_SECONDS,
modelId: selectedModel.id,
});
const createParams: OpenAI.VideoCreateParams = {
prompt,
model: selectedModel.id,
seconds: String(estimatedUnits) as OpenAI.VideoSeconds,
size: normalizedSize as OpenAI.VideoSize,
};
if (inputReference) {
createParams.input_reference =
inputReference as OpenAI.VideoCreateParams['input_reference'];
}
const createResponse = await this.#openai.videos.create(createParams);
const finalJob = await this.#pollUntilComplete(createResponse);
if (finalJob.status === 'failed') {
const errorMessage =
finalJob.error?.message ?? 'Video generation failed';
// Same reasoning as TogetherVideoProvider — Sora's `failed`
// status covers both user input issues (content policy) and
// their own outages; expose as `upstream_failed` 400 so the
// alarm gate skips it instead of paging on 500.
throw new HttpError(400, errorMessage, {
legacyCode: 'upstream_failed',
fields: { provider: 'openai' },
});
}
const finalResolution =
this.#normalizeSize(finalJob.size, selectedModel) ?? normalizedSize;
const finalTier = this.#determineSizeTier(
selectedModel,
finalResolution,
);
const finalCostPerSecondCents = this.#getCostPerSecond(
selectedModel,
finalTier,
);
if (!finalCostPerSecondCents) {
throw new Error(
`No pricing configured for model ${selectedModel.id} at size ${finalResolution}`,
);
}
const finalCostInMicroCents = finalCostPerSecondCents * 1_000_000;
const actualSeconds =
this.#parseSeconds(finalJob.seconds) ?? estimatedUnits;
const downloadResponse = await this.#openai.videos.downloadContent(
finalJob.id,
);
const contentType =
downloadResponse.headers.get('content-type') ?? 'video/mp4';
let stream: any = downloadResponse.body;
if (stream && typeof stream.getReader === 'function') {
stream = Readable.fromWeb(stream as any);
}
if (!stream) {
const arrayBuffer = await downloadResponse.arrayBuffer();
stream = Readable.from(Buffer.from(arrayBuffer));
}
const finalUsageKey = this.#getUsageKey(selectedModel, finalTier);
await this.#meteringService.incrementUsage(
actor,
finalUsageKey,
actualSeconds,
finalCostInMicroCents * actualSeconds,
);
return {
stream,
content_type: contentType,
};
}
async #selectModel(
requestedModel?: string,
): Promise<IVideoModel | undefined> {
const allModels = await this.models();
return allModels.find(
(m) => m.id.toLowerCase() === requestedModel?.toLowerCase(),
);
}
async #pollUntilComplete(initialJob: OpenAI.Video): Promise<OpenAI.Video> {
let job = initialJob;
const start = Date.now();
while (job.status === 'queued' || job.status === 'in_progress') {
if (Date.now() - start > DEFAULT_TIMEOUT_MS) {
throw pollTimeoutError('openai', 'Sora');
}
await this.#delay(POLL_INTERVAL_MS);
job = await this.#openai.videos.retrieve(job.id);
}
return job;
}
async #delay(ms: number): Promise<void> {
return await new Promise((resolve) => setTimeout(resolve, ms));
}
#normalizeSize(candidate: unknown, model: IVideoModel): string | undefined {
if (!candidate) return undefined;
const normalized = this.#normalizeResolution(candidate);
if (normalized && model.dimensions?.includes(normalized)) {
return normalized;
}
return undefined;
}
#normalizeSeconds(value: unknown): string | undefined {
if (value === null || value === undefined) {
return undefined;
}
const parsed =
typeof value === 'number'
? String(Math.round(value))
: typeof value === 'string'
? value.trim()
: undefined;
if (
parsed &&
OPENAI_VIDEO_ALLOWED_SECONDS.includes(
Number(parsed) as (typeof OPENAI_VIDEO_ALLOWED_SECONDS)[number],
)
) {
return parsed;
}
return undefined;
}
#determineSizeTier(model: IVideoModel, size: string): string {
if (model.id === 'sora-2-pro') {
if (size === '1080x1920' || size === '1920x1080') return 'xxl';
if (size === '1024x1792' || size === '1792x1024') return 'xl';
}
return 'default';
}
#getCostPerSecond(model: IVideoModel, tier: string): number | undefined {
const key = tier === 'default' ? 'per-second' : `per-second-${tier}`;
return model.costs?.[key];
}
#getUsageKey(model: IVideoModel, tier: string): string {
return `openai:${model.id}:${tier}`;
}
#normalizeResolution(value: unknown): string | undefined {
if (!value) return undefined;
if (typeof value === 'string') {
const match = value.match(/(\d+)\s*x\s*(\d+)/i);
if (match) {
const w = Number.parseInt(match[1], 10);
const h = Number.parseInt(match[2], 10);
if (Number.isFinite(w) && Number.isFinite(h)) {
return `${w}x${h}`;
}
}
}
return undefined;
}
#parseSeconds(value: unknown): number | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value === 'string') {
const numeric = Number.parseInt(value, 10);
return Number.isFinite(numeric) ? numeric : undefined;
}
return undefined;
}
}
@@ -1,66 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { IVideoModel } from '../../types.js';
export const OPENAI_VIDEO_ALLOWED_SECONDS = [4, 8, 12] as const;
export const OPENAI_VIDEO_MODELS: IVideoModel[] = [
{
id: 'sora-2',
puterId: 'openai:openai/sora-2',
aliases: ['openai/sora-2'],
name: 'Sora 2',
costs_currency: 'usd-cents',
costs: {
'per-second': 10,
'default-duration-per-video': 40,
},
output_cost_key: 'default-duration-per-video',
durationSeconds: OPENAI_VIDEO_ALLOWED_SECONDS.slice(),
dimensions: ['720x1280', '1280x720'],
defaultUsageKey: 'openai:sora-2:default',
},
{
id: 'sora-2-pro',
puterId: 'openai:openai/sora-2-pro',
aliases: ['openai/sora-2-pro'],
name: 'Sora 2 Pro',
costs_currency: 'usd-cents',
costs: {
'per-second': 30,
'default-duration-per-video': 120,
'per-second-xl': 50,
'default-duration-per-video-xl': 200,
'per-second-xxl': 70,
'default-duration-per-video-xxl': 280,
},
output_cost_key: 'default-duration-per-video',
durationSeconds: OPENAI_VIDEO_ALLOWED_SECONDS.slice(),
dimensions: [
'720x1280',
'1280x720',
'1024x1792',
'1792x1024',
'1080x1920',
'1920x1080',
],
defaultUsageKey: 'openai:sora-2-pro:default',
},
];
@@ -82,8 +82,12 @@ vi.mock('together-ai', () => {
let server: PuterServer;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
// Plenty of credit for every per-second test that isn't about the cap.
const AMPLE_CREDIT = 100_000_000_000;
beforeAll(async () => {
server = await setupTestServer();
});
@@ -107,6 +111,8 @@ beforeEach(() => {
// for the explicit credit-gate scenarios. SYSTEM_ACTOR has no uuid,
// so the live `getRemainingUsage` path can short-circuit to 0.
hasCreditsSpy.mockResolvedValue(true);
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
remainingUsageSpy.mockResolvedValue(AMPLE_CREDIT);
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
@@ -594,6 +600,147 @@ describe('TogetherVideoProvider.generate polling', () => {
});
});
// ── Per-second models ───────────────────────────────────────────────
describe('TogetherVideoProvider.generate per-second models', () => {
const completeJob = (outputs: Record<string, unknown> = {}) => {
videosCreateMock.mockResolvedValueOnce({ id: 'job-ps' });
videosRetrieveMock.mockResolvedValueOnce({
id: 'job-ps',
status: 'completed',
outputs: { video_url: 'https://together/ps.mp4', ...outputs },
});
};
it('sends a resolution tier instead of width/height and estimates from the requested duration', async () => {
const provider = makeProvider();
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:bytedance/seedance-2.5',
seconds: 10,
size: '480p',
width: 1280,
height: 720,
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.model).toBe('ByteDance/Seedance-2.5');
expect(sent.resolution).toBe('480p');
expect(sent.seconds).toBe('10');
expect('width' in sent).toBe(false);
expect('height' in sent).toBe(false);
expect('ratio' in sent).toBe(false);
// The flat-rate gate is not consulted for per-second models.
expect(hasCreditsSpy).not.toHaveBeenCalled();
const seedance = TOGETHER_VIDEO_GENERATION_MODELS.find(
(m) => m.model === 'ByteDance/Seedance-2.5',
)!;
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('together-video:ByteDance/Seedance-2.5');
expect(count).toBe(10);
expect(cost).toBe(
Math.round(seedance.costs!['per-second-480p'] * 10 * 1_000_000),
);
});
it('defaults to the first tier and derives ratio from width/height on Wan 2.7', async () => {
const provider = makeProvider();
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:wan-ai/wan2.7-t2v',
seconds: 5,
width: 1920,
height: 1080,
}),
);
let sent = videosCreateMock.mock.calls[0]![0];
expect(sent.model).toBe('Wan-AI/wan2.7-t2v');
expect(sent.resolution).toBe('720P');
expect(sent.ratio).toBe('16:9');
expect('width' in sent).toBe(false);
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:wan-ai/wan2.7-t2v',
size: '1080p',
width: 999,
height: 100,
}),
);
sent = videosCreateMock.mock.calls[1]![0];
// A case-insensitive tier match forwards the catalog spelling; an
// unsupported ratio is left to the model.
expect(sent.resolution).toBe('1080P');
expect('ratio' in sent).toBe(false);
});
it('forwards generate_audio when set', async () => {
const provider = makeProvider();
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:bytedance/seedance-2.0',
generate_audio: false,
}),
);
expect(videosCreateMock.mock.calls[0]![0].generate_audio).toBe(false);
});
// Seedance 2.5 at its default 720p tier is 24.9 usd-cents/second and
// accepts any whole number of seconds from 4 to 30.
it('caps the clip to the longest supported duration the credit buys', async () => {
const provider = makeProvider();
const perSecondMicroCents = 24.9 * 1_000_000;
remainingUsageSpy.mockResolvedValueOnce(7.5 * perSecondMicroCents);
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:bytedance/seedance-2.5',
seconds: 10,
}),
);
expect(videosCreateMock.mock.calls[0]![0].seconds).toBe('7');
const [, , count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(count).toBe(7);
expect(cost).toBe(Math.round(7 * perSecondMicroCents));
});
it('throws 402 BEFORE hitting Together when the balance cannot buy the shortest clip', async () => {
const provider = makeProvider();
remainingUsageSpy.mockResolvedValueOnce(3.5 * 24.9 * 1_000_000);
await expect(
withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:bytedance/seedance-2.5',
seconds: 10,
}),
),
).rejects.toMatchObject({
statusCode: 402,
legacyCode: 'insufficient_funds',
});
expect(videosCreateMock).not.toHaveBeenCalled();
});
});
// ── Cost reporting & metering ───────────────────────────────────────
describe('TogetherVideoProvider.generate metering', () => {
@@ -626,6 +773,28 @@ describe('TogetherVideoProvider.generate metering', () => {
expect(cost).toBe(expectedCost);
});
it('bills the cost Together reports on the finished job when present', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({ id: 'job-1' });
videosRetrieveMock.mockResolvedValueOnce({
id: 'job-1',
status: 'completed',
outputs: { cost: 0.31, video_url: 'https://together/out.mp4' },
});
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:minimax/video-01-director',
}),
);
const [, , count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(count).toBe(1);
// $0.31 → 31 cents → 31,000,000 microcents, not the 28-cent catalog rate.
expect(cost).toBe(31_000_000);
});
it('does NOT meter when the job fails', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({ id: 'job-fail' });
@@ -656,3 +825,54 @@ describe('TogetherVideoProvider.generate error paths', () => {
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// ── Unified keyframe names ─────────────────────────────────────────
describe('TogetherVideoProvider.generate input_reference / last_frame', () => {
const completeJob = () => {
videosCreateMock.mockResolvedValueOnce({ id: 'job-kf' });
videosRetrieveMock.mockResolvedValueOnce({
id: 'job-kf',
status: 'completed',
outputs: { video_url: 'https://together/kf.mp4' },
});
};
it('maps input_reference and last_frame onto first/last frame_images', async () => {
const provider = makeProvider();
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'togetherai:bytedance/seedance-2.5',
input_reference: 'https://example.com/first.png',
last_frame: 'https://example.com/last.png',
}),
);
expect(videosCreateMock.mock.calls[0]![0].frame_images).toEqual([
{ input_image: 'https://example.com/first.png', frame: 'first' },
{ input_image: 'https://example.com/last.png', frame: 'last' },
]);
});
it('lets an explicit frame_images win over input_reference', async () => {
const provider = makeProvider();
completeJob();
await withTestActor(() =>
provider.generate({
prompt: 'hi',
input_reference: 'https://example.com/ignored.png',
frame_images: [
{ input_image: 'https://example.com/keyframe.png', frame: 0 },
] as never,
}),
);
expect(videosCreateMock.mock.calls[0]![0].frame_images).toEqual([
{ input_image: 'https://example.com/keyframe.png', frame: 0 },
]);
});
});
@@ -22,9 +22,13 @@ import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
import { capSecondsToRemainingCredits } from '../../creditCap.js';
import { VideoProvider } from '../VideoProvider.js';
import { pollUntilSettled, videoJobFailure } from '../polling.js';
import { TOGETHER_VIDEO_GENERATION_MODELS } from './models.js';
import {
TOGETHER_VIDEO_GENERATION_MODELS,
type ITogetherVideoModel,
} from './models.js';
const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
const POLL_INTERVAL_MS = 5_000;
@@ -32,6 +36,19 @@ const REQUEST_TIMEOUT_MS = 60 * 1000;
const DEFAULT_MODEL = 'minimax/video-01-director';
const DEFAULT_DURATION_SECONDS = 6;
// Resolution tiers ('720p', '1080P') mark models that size their output
// through `resolution` (+ `ratio`) rather than width/height.
const isResolutionTier = (value: string): boolean => /^\d{3,4}p$/i.test(value);
// The SDK's create params trail the API: `resolution`, `ratio` and
// `generate_audio` are documented request fields it does not type yet.
type TogetherCreatePayload = Together.VideoCreateParams & {
metadata?: object;
resolution?: string;
ratio?: string;
generate_audio?: boolean;
};
export class TogetherVideoProvider extends VideoProvider {
#client: Together;
#meteringService: MeteringService;
@@ -74,6 +91,8 @@ export class TogetherVideoProvider extends VideoProvider {
seconds,
no_extra_params,
duration,
size,
resolution,
width,
height,
fps,
@@ -83,8 +102,11 @@ export class TogetherVideoProvider extends VideoProvider {
output_format: outputFormat,
output_quality: outputQuality,
negative_prompt: negativePrompt,
generate_audio: generateAudio,
reference_images: referenceImages,
frame_images: frameImages,
input_reference: inputReference,
last_frame: lastFrame,
metadata,
test_mode: testMode,
} = params ?? {};
@@ -95,7 +117,7 @@ export class TogetherVideoProvider extends VideoProvider {
});
}
const selectedModel = await this.#getModel(requestedModel);
const selectedModel = this.#getModel(requestedModel);
const model =
selectedModel?.model ??
this.#stripTogetherPrefix(requestedModel ?? DEFAULT_MODEL);
@@ -104,11 +126,19 @@ export class TogetherVideoProvider extends VideoProvider {
return DEFAULT_TEST_VIDEO_URL;
}
const costPerVideoCents = selectedModel?.costs?.['per-video'];
if (!costPerVideoCents) {
const costs = selectedModel?.costs ?? {};
const resolutionTier = this.#resolveResolutionTier(
size ?? resolution,
selectedModel,
);
const perSecondCents =
(resolutionTier !== undefined
? costs[`per-second-${resolutionTier.toLowerCase()}`]
: undefined) ?? costs['per-second'];
const perVideoCents = costs['per-video'];
if (!perSecondCents && !perVideoCents) {
throw new Error(`No pricing configured for video model ${model}`);
}
const costInMicroCents = costPerVideoCents * 1_000_000;
let normalizedSeconds = this.#coercePositiveInteger(
seconds ?? duration,
@@ -125,19 +155,38 @@ export class TogetherVideoProvider extends VideoProvider {
});
}
const usageAllowed = await this.#meteringService.hasEnoughCredits(
actor,
costInMicroCents,
);
if (!usageAllowed) {
throw new HttpError(402, 'Insufficient funds', {
legacyCode: 'insufficient_funds',
// Per-second models are clamped to what the balance buys, like Veo
// and Seedance; per-clip models stay all-or-nothing.
let estimateMicroCents: number;
let billedUnits: number;
if (perSecondCents) {
normalizedSeconds = await capSecondsToRemainingCredits({
metering: this.#meteringService,
actor,
perSecondMicroCents: perSecondCents * 1_000_000,
requestedSeconds: normalizedSeconds ?? DEFAULT_DURATION_SECONDS,
allowedSeconds: selectedModel?.durationSeconds,
modelId: model,
});
estimateMicroCents = Math.round(
perSecondCents * 1_000_000 * normalizedSeconds,
);
billedUnits = normalizedSeconds;
} else {
estimateMicroCents = perVideoCents! * 1_000_000;
const usageAllowed = await this.#meteringService.hasEnoughCredits(
actor,
estimateMicroCents,
);
if (!usageAllowed) {
throw new HttpError(402, 'Insufficient funds', {
legacyCode: 'insufficient_funds',
});
}
billedUnits = 1;
}
const createPayload: Together.VideoCreateParams & {
metadata?: object;
} = {
const createPayload: TogetherCreatePayload = {
prompt,
model,
};
@@ -145,11 +194,23 @@ export class TogetherVideoProvider extends VideoProvider {
if (normalizedSeconds) {
createPayload.seconds = String(normalizedSeconds);
}
if (this.#isFiniteNumber(width)) {
createPayload.width = Number(width);
}
if (this.#isFiniteNumber(height)) {
createPayload.height = Number(height);
if (resolutionTier !== undefined) {
createPayload.resolution = resolutionTier;
const ratio = this.#deriveRatio(
width,
height,
selectedModel?.ratios,
);
if (ratio) {
createPayload.ratio = ratio;
}
} else {
if (this.#isFiniteNumber(width)) {
createPayload.width = Number(width);
}
if (this.#isFiniteNumber(height)) {
createPayload.height = Number(height);
}
}
if (this.#isFiniteNumber(fps)) {
createPayload.fps = Number(fps);
@@ -173,6 +234,9 @@ export class TogetherVideoProvider extends VideoProvider {
if (typeof negativePrompt === 'string' && negativePrompt.trim()) {
createPayload.negative_prompt = negativePrompt;
}
if (typeof generateAudio === 'boolean') {
createPayload.generate_audio = generateAudio;
}
if (Array.isArray(referenceImages) && referenceImages.length > 0) {
createPayload.reference_images = referenceImages.filter(
(item: string) =>
@@ -186,6 +250,22 @@ export class TogetherVideoProvider extends VideoProvider {
typeof frame === 'object' &&
typeof frame.input_image === 'string',
) as Together.VideoCreateParams['frame_images'];
} else {
// `input_reference` / `last_frame` are the cross-provider names
// for Together's keyframes; an explicit `frame_images` wins.
const keyframes = [
[inputReference, 'first'],
[lastFrame, 'last'],
]
.filter(([image]) => typeof image === 'string' && image.trim())
.map(([image, frame]) => ({
input_image: (image as string).trim(),
frame,
}));
if (keyframes.length > 0) {
createPayload.frame_images =
keyframes as unknown as Together.VideoCreateParams['frame_images'];
}
}
if (metadata && typeof metadata === 'object') {
createPayload.metadata = metadata;
@@ -214,12 +294,22 @@ export class TogetherVideoProvider extends VideoProvider {
throw videoJobFailure('together', 'Video generation was cancelled');
}
// Together reports what it actually charged for the job; the catalog
// rate above was only the pre-flight estimate.
const reportedCost = finalJob?.outputs?.cost;
const costMicroCents =
typeof reportedCost === 'number' &&
Number.isFinite(reportedCost) &&
reportedCost >= 0
? Math.round(reportedCost * 100 * 1_000_000)
: estimateMicroCents;
const usageKey = `together-video:${model}`;
await this.#meteringService.incrementUsage(
actor,
usageKey,
1,
costInMicroCents,
billedUnits,
costMicroCents,
);
const videoUrl = finalJob?.outputs?.video_url;
@@ -242,13 +332,12 @@ export class TogetherVideoProvider extends VideoProvider {
});
}
async #getModel(requestedModel?: string): Promise<IVideoModel | undefined> {
#getModel(requestedModel?: string): ITogetherVideoModel | undefined {
const bareModel = this.#stripTogetherPrefix(
requestedModel ?? DEFAULT_MODEL,
);
const allModels = await this.models();
return allModels.find(
(m) => m.model?.toLowerCase() === bareModel.toLowerCase(),
return TOGETHER_VIDEO_GENERATION_MODELS.find(
(m) => m.model.toLowerCase() === bareModel.toLowerCase(),
);
}
@@ -259,6 +348,47 @@ export class TogetherVideoProvider extends VideoProvider {
return model;
}
/**
* The catalog spelling of the requested tier, or the model's default tier;
* undefined for models sized by width/height.
*/
#resolveResolutionTier(
candidate: unknown,
model?: ITogetherVideoModel,
): string | undefined {
const tiers = (model?.dimensions ?? []).filter(isResolutionTier);
if (tiers.length === 0) return undefined;
if (typeof candidate === 'string') {
const wanted = candidate.trim().toLowerCase();
const match = tiers.find((t) => t.toLowerCase() === wanted);
if (match) return match;
}
return tiers[0];
}
/** Snap width/height to one of the model's `ratio` strings. */
#deriveRatio(
width?: number,
height?: number,
ratios?: string[] | null,
): string | undefined {
if (
!ratios?.length ||
!this.#isFiniteNumber(width) ||
!this.#isFiniteNumber(height)
) {
return undefined;
}
const w = Math.round(Number(width));
const h = Math.round(Number(height));
if (w <= 0 || h <= 0) return undefined;
const gcd = (a: number, b: number): number =>
b === 0 ? a : gcd(b, a % b);
const d = gcd(w, h) || 1;
const candidate = `${w / d}:${h / d}`;
return ratios.includes(candidate) ? candidate : undefined;
}
#coercePositiveInteger(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
const rounded = Math.round(value);
@@ -19,17 +19,46 @@
import { IVideoModel } from '../../types.js';
interface ITogetherVideoModel extends IVideoModel {
/**
* Together prices its older video models per clip (`per-video`) and its newer
* ones per second of output (`per-second`, plus `per-second-<tier>` where a
* cheaper resolution tier exists). Per-second rates are the highest rate
* Together publishes for the model so the pre-flight estimate never
* undershoots; the provider then bills the amount Together reports on the
* finished job. `default-duration-per-video` is the estimated cents for a 5s
* clip and only feeds cross-provider cost sorting.
*/
export interface ITogetherVideoModel extends IVideoModel {
model: string;
organization: string;
durationSeconds: number[] | null;
/**
* Pixel sizes the model takes as width/height, or resolution tiers ('720p')
* it takes as `resolution`. The first entry is the default.
*/
dimensions: string[] | null;
fps: number[] | null;
keyframes: string[] | null;
promptLength: { min: number; max: number } | null;
promptSupported: boolean | null;
/**
* Aspect ratios accepted as `ratio`, derived from width/height. Only
* tier-sized models take one.
*/
ratios?: string[] | null;
}
// Duration ladder for the driver's normalization: element 0 is the default,
// the rest enumerate the model's contiguous valid range.
const seconds = (def: number, min: number, max: number): number[] => [
def,
...Array.from({ length: max - min + 1 }, (_, i) => min + i).filter(
(s) => s !== def,
),
];
// Catalog from https://api.together.xyz/v1/models (type=video) and
// https://docs.together.ai/docs/serverless/models.
export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
{
id: 'togetherai:minimax/video-01-director',
@@ -79,68 +108,38 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
promptLength: { min: 2, max: 3000 },
promptSupported: true,
},
// Google serves Veo 3.1 directly too; these entries exist for callers who
// pin `togetherai:`. Together's own listing quotes $0.60/s with audio.
{
id: 'togetherai:google/veo-3.0',
puterId: 'togetherai:google/veo-3.0',
id: 'togetherai:google/veo-3.1',
puterId: 'togetherai:google/veo-3.1',
organization: 'Google',
name: 'Veo 3.0',
model: 'google/veo-3.0',
name: 'Veo 3.1',
model: 'google/veo-3.1',
costs_currency: 'usd-cents',
costs: { 'per-video': 160 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: ['1280x720', '720x1280', '1920x1080', '1080x1920'],
costs: { 'per-second': 60, 'default-duration-per-video': 300 },
output_cost_key: 'default-duration-per-video',
durationSeconds: [4, 6, 8],
dimensions: null,
fps: [24],
keyframes: ['first'],
promptLength: { min: 2, max: 3000 },
keyframes: ['first', 'last'],
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:google/veo-3.0-audio',
puterId: 'togetherai:google/veo-3.0-audio',
id: 'togetherai:google/veo-3.1-lite',
puterId: 'togetherai:google/veo-3.1-lite',
organization: 'Google',
name: 'Veo 3.0 + Audio',
model: 'google/veo-3.0-audio',
name: 'Veo 3.1 Lite',
model: 'google/veo-3.1-lite',
costs_currency: 'usd-cents',
costs: { 'per-video': 320 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: ['1280x720', '720x1280', '1920x1080', '1080x1920'],
costs: { 'per-second': 8, 'default-duration-per-video': 40 },
output_cost_key: 'default-duration-per-video',
durationSeconds: [4, 6, 8],
dimensions: null,
fps: [24],
keyframes: ['first'],
promptLength: { min: 2, max: 3000 },
promptSupported: true,
},
{
id: 'togetherai:google/veo-3.0-fast',
puterId: 'togetherai:google/veo-3.0-fast',
organization: 'Google',
name: 'Veo 3.0 Fast',
model: 'google/veo-3.0-fast',
costs_currency: 'usd-cents',
costs: { 'per-video': 80 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: ['1280x720', '720x1280', '1920x1080', '1080x1920'],
fps: [24],
keyframes: ['first'],
promptLength: { min: 2, max: 3000 },
promptSupported: true,
},
{
id: 'togetherai:google/veo-3.0-fast-audio',
puterId: 'togetherai:google/veo-3.0-fast-audio',
organization: 'Google',
name: 'Veo 3.0 Fast + Audio',
model: 'google/veo-3.0-fast-audio',
costs_currency: 'usd-cents',
costs: { 'per-video': 120 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: ['1280x720', '720x1280', '1920x1080', '1080x1920'],
fps: [24],
keyframes: ['first'],
promptLength: { min: 2, max: 3000 },
keyframes: ['first', 'last'],
promptLength: null,
promptSupported: true,
},
{
@@ -150,7 +149,7 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
name: 'Seedance 1.0 Lite',
model: 'ByteDance/Seedance-1.0-lite',
costs_currency: 'usd-cents',
costs: { 'per-video': 14 },
costs: { 'per-video': 14.3 },
output_cost_key: 'per-video',
durationSeconds: [5],
dimensions: [
@@ -197,6 +196,45 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
promptLength: { min: 2, max: 3000 },
promptSupported: true,
},
// Together quotes 720P text/image-to-video at $0.16/s and video-to-video
// "from $0.28/s"; the higher rate is the estimate since reference inputs
// can route to the latter.
{
id: 'togetherai:ByteDance/Seedance-2.0',
puterId: 'togetherai:bytedance/seedance-2.0',
organization: 'ByteDance',
name: 'Seedance 2.0',
model: 'ByteDance/Seedance-2.0',
costs_currency: 'usd-cents',
costs: { 'per-second': 28, 'default-duration-per-video': 140 },
output_cost_key: 'default-duration-per-video',
durationSeconds: seconds(5, 4, 15),
dimensions: null,
fps: [24],
keyframes: ['first', 'last'],
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:ByteDance/Seedance-2.5',
puterId: 'togetherai:bytedance/seedance-2.5',
organization: 'ByteDance',
name: 'Seedance 2.5',
model: 'ByteDance/Seedance-2.5',
costs_currency: 'usd-cents',
costs: {
'per-second': 24.9,
'per-second-480p': 11.5,
'default-duration-per-video': 125,
},
output_cost_key: 'default-duration-per-video',
durationSeconds: seconds(5, 4, 30),
dimensions: ['720p', '480p'],
fps: [24],
keyframes: ['first', 'last'],
promptLength: { min: 2, max: 10000 },
promptSupported: true,
},
{
id: 'togetherai:pixverse/pixverse-v5',
puterId: 'togetherai:pixverse/pixverse-v5',
@@ -234,6 +272,39 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
promptLength: { min: 2, max: 2048 },
promptSupported: true,
},
// Together quotes $0.1031-$0.221 per 5s clip plus $0.1326 for audio.
{
id: 'togetherai:pixverse/pixverse-v5.6',
puterId: 'togetherai:pixverse/pixverse-v5.6',
organization: 'PixVerse',
name: 'PixVerse v5.6',
model: 'pixverse/pixverse-v5.6',
costs_currency: 'usd-cents',
costs: { 'per-second': 7.1, 'default-duration-per-video': 36 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:pixverse/pixverse-v6',
puterId: 'togetherai:pixverse/pixverse-v6',
organization: 'PixVerse',
name: 'PixVerse v6',
model: 'pixverse/pixverse-v6',
costs_currency: 'usd-cents',
costs: { 'per-second': 11.5, 'default-duration-per-video': 58 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:kwaivgI/kling-2.1-master',
puterId: 'togetherai:kwaivgi/kling-2.1-master',
@@ -282,22 +353,6 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
promptLength: null,
promptSupported: false,
},
{
id: 'togetherai:kwaivgI/kling-2.0-master',
puterId: 'togetherai:kwaivgi/kling-2.0-master',
organization: 'Kuaishou',
name: 'Kling 2.0 Master',
model: 'kwaivgI/kling-2.0-master',
costs_currency: 'usd-cents',
costs: { 'per-video': 92 },
output_cost_key: 'per-video',
durationSeconds: [5],
dimensions: ['1280x720', '720x720', '720x1280'],
fps: [24],
keyframes: ['first'],
promptLength: { min: 2, max: 2500 },
promptSupported: true,
},
{
id: 'togetherai:kwaivgI/kling-1.6-standard',
puterId: 'togetherai:kwaivgi/kling-1.6-standard',
@@ -314,54 +369,8 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
promptLength: { min: 2, max: 2500 },
promptSupported: true,
},
{
id: 'togetherai:kwaivgI/kling-1.6-pro',
puterId: 'togetherai:kwaivgi/kling-1.6-pro',
organization: 'Kuaishou',
name: 'Kling 1.6 Pro',
model: 'kwaivgI/kling-1.6-pro',
costs_currency: 'usd-cents',
costs: { 'per-video': 32 },
output_cost_key: 'per-video',
durationSeconds: [5],
dimensions: ['1920x1080', '1080x1080', '1080x1920'],
fps: [24],
keyframes: ['first'],
promptLength: null,
promptSupported: false,
},
{
id: 'togetherai:Wan-AI/Wan2.2-I2V-A14B',
puterId: 'togetherai:wan-ai/wan2.2-i2v-a14b',
organization: 'Wan-AI',
name: 'Wan 2.2 I2V',
model: 'Wan-AI/Wan2.2-I2V-A14B',
costs_currency: 'usd-cents',
costs: { 'per-video': 31 },
output_cost_key: 'per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: null,
},
{
id: 'togetherai:Wan-AI/Wan2.2-T2V-A14B',
puterId: 'togetherai:wan-ai/wan2.2-t2v-a14b',
organization: 'Wan-AI',
name: 'Wan 2.2 T2V',
model: 'Wan-AI/Wan2.2-T2V-A14B',
costs_currency: 'usd-cents',
costs: { 'per-video': 66 },
output_cost_key: 'per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: null,
},
// Wan 2.7 sizes output by `resolution` + `ratio` and quotes a flat
// $0.10 per 5 seconds.
{
id: 'togetherai:Wan-AI/wan2.7-t2v',
puterId: 'togetherai:wan-ai/wan2.7-t2v',
@@ -369,39 +378,145 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
name: 'Wan 2.7 T2V',
model: 'Wan-AI/wan2.7-t2v',
costs_currency: 'usd-cents',
costs: { 'per-video': 10 },
output_cost_key: 'per-video',
costs: { 'per-second': 2, 'default-duration-per-video': 10 },
output_cost_key: 'default-duration-per-video',
durationSeconds: seconds(5, 2, 15),
dimensions: ['720P', '1080P'],
ratios: ['16:9', '9:16', '1:1', '4:3', '3:4'],
fps: [30],
keyframes: null,
promptLength: { min: 1, max: 5000 },
promptSupported: true,
},
{
id: 'togetherai:Wan-AI/wan2.7-i2v',
puterId: 'togetherai:wan-ai/wan2.7-i2v',
organization: 'Wan-AI',
name: 'Wan 2.7 I2V',
model: 'Wan-AI/wan2.7-i2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 2, 'default-duration-per-video': 10 },
output_cost_key: 'default-duration-per-video',
durationSeconds: seconds(5, 2, 15),
dimensions: ['720P', '1080P'],
ratios: ['16:9', '9:16', '1:1', '4:3', '3:4'],
fps: [30],
keyframes: ['first', 'last'],
promptLength: { min: 1, max: 5000 },
promptSupported: true,
},
{
id: 'togetherai:Wan-AI/wan2.7-r2v',
puterId: 'togetherai:wan-ai/wan2.7-r2v',
organization: 'Wan-AI',
name: 'Wan 2.7 R2V',
model: 'Wan-AI/wan2.7-r2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 2, 'default-duration-per-video': 10 },
output_cost_key: 'default-duration-per-video',
durationSeconds: seconds(5, 2, 10),
dimensions: ['720P', '1080P'],
ratios: ['16:9', '9:16', '1:1', '4:3', '3:4'],
fps: [30],
keyframes: null,
promptLength: { min: 1, max: 5000 },
promptSupported: true,
},
// HappyHorse: $0.14/s at 720P, $0.24/s (1.0) or $0.18/s (1.1) at 1080p.
{
id: 'togetherai:alibaba/happyhorse-1.0-t2v',
puterId: 'togetherai:alibaba/happyhorse-1.0-t2v',
organization: 'Alibaba',
name: 'HappyHorse 1.0 T2V',
model: 'alibaba/happyhorse-1.0-t2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 24, 'default-duration-per-video': 120 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: null,
promptSupported: true,
},
{
id: 'togetherai:vidu/vidu-2.0',
puterId: 'togetherai:vidu/vidu-2.0',
organization: 'Vidu',
name: 'Vidu 2.0',
model: 'vidu/vidu-2.0',
id: 'togetherai:alibaba/happyhorse-1.0-i2v',
puterId: 'togetherai:alibaba/happyhorse-1.0-i2v',
organization: 'Alibaba',
name: 'HappyHorse 1.0 I2V',
model: 'alibaba/happyhorse-1.0-i2v',
costs_currency: 'usd-cents',
costs: { 'per-video': 28 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: [
'1920x1080',
'1080x1080',
'1080x1920',
'1280x720',
'720x720',
'720x1280',
'640x360',
'360x360',
'360x640',
],
fps: [24],
keyframes: ['first', 'last'],
promptLength: { min: 2, max: 3000 },
costs: { 'per-second': 24, 'default-duration-per-video': 120 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: ['first'],
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:alibaba/happyhorse-1.0-r2v',
puterId: 'togetherai:alibaba/happyhorse-1.0-r2v',
organization: 'Alibaba',
name: 'HappyHorse 1.0 R2V',
model: 'alibaba/happyhorse-1.0-r2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 24, 'default-duration-per-video': 120 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:alibaba/happyhorse-1.1-t2v',
puterId: 'togetherai:alibaba/happyhorse-1.1-t2v',
organization: 'Alibaba',
name: 'HappyHorse 1.1 T2V',
model: 'alibaba/happyhorse-1.1-t2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 18, 'default-duration-per-video': 90 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:alibaba/happyhorse-1.1-i2v',
puterId: 'togetherai:alibaba/happyhorse-1.1-i2v',
organization: 'Alibaba',
name: 'HappyHorse 1.1 I2V',
model: 'alibaba/happyhorse-1.1-i2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 18, 'default-duration-per-video': 90 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: ['first'],
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:alibaba/happyhorse-1.1-r2v',
puterId: 'togetherai:alibaba/happyhorse-1.1-r2v',
organization: 'Alibaba',
name: 'HappyHorse 1.1 R2V',
model: 'alibaba/happyhorse-1.1-r2v',
costs_currency: 'usd-cents',
costs: { 'per-second': 18, 'default-duration-per-video': 90 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: true,
},
{
@@ -420,36 +535,54 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [
promptLength: { min: 2, max: 3000 },
promptSupported: true,
},
// Vidu Q3: $0.0455-$0.104/s; Q3 Turbo: $0.13-$0.26/s, by resolution.
{
id: 'togetherai:openai/sora-2',
puterId: 'togetherai:openai/sora-2',
organization: 'OpenAI',
name: 'Sora 2',
model: 'openai/sora-2',
id: 'togetherai:vidu/vidu-q3',
puterId: 'togetherai:vidu/vidu-q3',
organization: 'Vidu',
name: 'Vidu Q3',
model: 'vidu/vidu-q3',
costs_currency: 'usd-cents',
costs: { 'per-video': 80 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: ['1280x720', '720x1280'],
costs: { 'per-second': 10.4, 'default-duration-per-video': 52 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: ['first'],
promptLength: { min: 1, max: 4000 },
keyframes: null,
promptLength: null,
promptSupported: true,
},
{
id: 'togetherai:openai/sora-2-pro',
puterId: 'togetherai:openai/sora-2-pro',
organization: 'OpenAI',
name: 'Sora 2 Pro',
model: 'openai/sora-2-pro',
id: 'togetherai:vidu/vidu-q3-turbo',
puterId: 'togetherai:vidu/vidu-q3-turbo',
organization: 'Vidu',
name: 'Vidu Q3 Turbo',
model: 'vidu/vidu-q3-turbo',
costs_currency: 'usd-cents',
costs: { 'per-video': 300 },
output_cost_key: 'per-video',
durationSeconds: [8],
dimensions: ['1280x720', '720x1280'],
costs: { 'per-second': 26, 'default-duration-per-video': 130 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: ['first'],
promptLength: { min: 1, max: 4000 },
keyframes: null,
promptLength: null,
promptSupported: true,
},
// FLUX 3 text-to-video: $0.17/s at 720p, $0.29/s at 1080p.
{
id: 'togetherai:black-forest-labs/FLUX-3',
puterId: 'togetherai:black-forest-labs/flux-3',
organization: 'Black Forest Labs',
name: 'FLUX 3',
model: 'black-forest-labs/FLUX-3',
costs_currency: 'usd-cents',
costs: { 'per-second': 29, 'default-duration-per-video': 145 },
output_cost_key: 'default-duration-per-video',
durationSeconds: null,
dimensions: null,
fps: null,
keyframes: null,
promptLength: null,
promptSupported: true,
},
];
+110
View File
@@ -0,0 +1,110 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Image inputs as drivers receive them: a public URL, a data-URI, or raw base64
* always a string. Providers whose upstream needs inline bytes normalize URLs
* server-side through the SSRF-guarded `secureFetch`; providers that accept
* URLs natively pass them through untouched.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { secureFetch } from '../../util/secureHttp.js';
export function isHttpUrl(s: unknown): boolean {
return (
typeof s === 'string' &&
(s.startsWith('http://') || s.startsWith('https://'))
);
}
/**
* An input image is a URL, a data-URI or raw base64 always a string. The
* field comes straight off the driver call, so the type has to be checked
* before anything reaches for `.startsWith`.
*/
export function assertInputImageString(img: unknown, label: string): string {
if (typeof img !== 'string') {
throw new HttpError(
400,
`${label}: each input image must be a URL, data-URI, or base64 string.`,
{ legacyCode: 'bad_request' },
);
}
return img;
}
/**
* Normalize an input-image string for providers that accept URLs natively:
* http(s) URLs and data-URIs pass through untouched, raw base64 is wrapped with
* `mimeHint` (default image/png).
*/
export function toUrlOrDataUri(img: string, mimeHint?: string): string {
assertInputImageString(img, 'input image');
return isHttpUrl(img) || img.startsWith('data:')
? img
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
export function parseDataUri(
s: string,
): { base64: string; mime: string } | null {
const m = DATA_URI_PATTERN.exec(s);
if (!m) return null;
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
}
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
export async function fetchImageAsBase64(
url: string,
): Promise<{ base64: string; mime: string }> {
const res = await secureFetch(url);
if (!res.ok) {
throw new HttpError(
400,
`Failed to fetch input image (status ${res.status})`,
{ legacyCode: 'bad_request' },
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
return { base64: buffer.toString('base64'), mime };
}
/**
* Normalize any input-image string to a base64 data-URI: http(s) URL
* fetched via secureFetch data-URI returned as-is raw base64 wrapped
* with `mimeHint` (default image/png)
*/
export async function toBase64DataUri(
img: string,
mimeHint?: string,
): Promise<string> {
assertInputImageString(img, 'input image');
if (img.startsWith('data:')) return img;
if (isHttpUrl(img)) {
const { base64, mime } = await fetchImageAsBase64(img);
return `data:${mime};base64,${base64}`;
}
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
+8 -3
View File
@@ -126,7 +126,7 @@ You can use AI models from various providers to perform tasks such as chat, text
<div class="example-content" data-section="text-to-video">
#### Generate a sample Sora clip
#### Generate a sample clip (test mode)
```html;ai-txt2vid
<html>
@@ -200,7 +200,7 @@ These AI features are supported out of the box when using Puter.js:
- **[`puter.ai.txt2speech.listEngines()`](/AI/txt2speech.listEngines/)** - List available TTS engines/models
- **[`puter.ai.txt2speech.listVoices()`](/AI/txt2speech.listVoices/)** - List available TTS voices
- **[`puter.ai.speech2speech()`](/AI/speech2speech/)** - Convert speech in one voice to another voice
- **[`puter.ai.txt2vid()`](/AI/txt2vid/)** - Generate short videos with OpenAI Sora models
- **[`puter.ai.txt2vid()`](/AI/txt2vid/)** - Generate short video clips from text or a reference image with Veo, Seedance and other models
- **[`puter.ai.speech2txt()`](/AI/speech2txt/)** - Transcribe or translate audio recordings into text
## Examples
@@ -234,7 +234,12 @@ You can see various Puter.js AI features in action from the following examples:
- [List TTS Voices](/playground/ai-txt2speech-list-voices/)
- [Transcribe audio with `speech2txt`](/AI/speech2txt/)
- Text to Video
- [Generate a sample Sora clip](/AI/txt2vid/)
- [Generate a sample clip (test mode)](/playground/ai-txt2vid/)
- [Text to Video with options](/playground/ai-txt2vid-options/)
- [Text to Video with Google Veo](/playground/ai-txt2vid-veo/)
- [Animate a photo (image-to-video)](/playground/ai-txt2vid-image-to-video/)
- [Save the clip to the Puter filesystem](/playground/ai-txt2vid-save/)
- [Show progress and handle errors](/playground/ai-txt2vid-errors/)
- Speech to Speech
- [Convert speech in one voice to another voice](/playground/ai-speech2speech-url/)
- [Convert speech in one voice to another voice with a recording stored as a file](/playground/ai-speech2speech-file/)
+317 -45
View File
@@ -1,10 +1,10 @@
---
title: puter.ai.txt2vid()
description: Generate short-form videos with AI models through Puter.js.
description: Generate short video clips from text or reference images with Veo, Seedance, Kling, Wan and other models through Puter.js.
platforms: [websites, apps, nodejs, workers]
---
Create AI-generated video clips directly from text prompts.
Given a prompt, generate a short video clip using AI. Puter routes the request to one of three upstream providers (Google Veo, Together AI, BytePlus Seedance) based on the model you pick, waits for the clip to render, and resolves with a ready-to-play video. Every model can also start from an image you supply (image-to-video), and the same option names work across providers.
## Syntax
@@ -18,73 +18,173 @@ puter.ai.txt2vid({prompt, ...options})
#### `prompt` (String) (required)
The text description that guides the video generation.
The text description that guides the video generation. Describe the subject, the motion, the camera move and the mood; cues such as "slow motion", "aerial shot" or "handheld" are understood by most models.
#### `testMode` (Boolean) (optional)
When `true`, the call returns a sample video so you can test your UI without incurring usage. Defaults to `false`.
When `true`, the call returns a short sample clip hosted by Puter instead of contacting a provider, so you can build your UI without spending credits. Defaults to `false`.
Test mode still resolves the `model` you asked for and still validates (and writes to) `puter_output_path`, so it catches misspelled model ids and permission problems before you go live.
#### `options` (Object) (optional)
Additional settings for the generation request. Available options depend on the provider.
Additional settings for the generation request. The options below carry the same meaning on every provider; each provider then accepts a few extras, listed in the sections that follow. Any option a provider does not recognize is ignored.
| Option | Type | Description |
|--------|------|-------------|
| `prompt` | `String` | Text description for the video generation |
| `model` | `String` | Video model to use (provider-specific). Defaults to `'sora-2'` |
| `seconds` | `Number` | Target clip length in seconds |
| `model` | `String` | Video model to use. Defaults to `'veo-3.1-lite'`. See [Choosing a model](#choosing-a-model) |
| `provider` | `String` | Pin the request to one provider: `'gemini-video-generation'`, `'together-video-generation'` or `'byteplus-video-generation'`. Only needed when a model id exists on more than one provider |
| `seconds` | `Number` | Target clip length in seconds. Each model supports a fixed set of durations; an unsupported value falls back to the model default instead of failing. `duration` is an alias |
| `size` | `String` | Output size as `'WIDTHxHEIGHT'` (e.g. `'1280x720'`) on every provider. Models that work in resolution tiers take the tier of the shorter side plus the aspect ratio, and also accept the tier directly (`'720p'`). An unsupported value falls back to the model default. `resolution` is an alias |
| `width`, `height` | `Number` | Output size in pixels on Together AI models sized in pixels; the aspect ratio on Seedance and Wan 2.7. Filled in from `size` when you leave them out |
| `input_reference` | `String` | Image the clip starts from (image-to-video): a public URL, a `data:` URI or raw base64, on every provider |
| `last_frame` | `String` | Image the clip ends on, same formats. Requires `input_reference` on Seedance |
| `reference_images` | `Array<String>` | Images the model keeps consistent as subjects or style, same formats. Limits per provider are listed below; not combined with `input_reference` or `last_frame` |
| `negative_prompt` | `String` | Text describing what to avoid in the video (Veo and Together AI) |
| `seed` | `Number` | Random seed for reproducible results (Together AI and Seedance 1.x) |
| `generate_audio` | `Boolean` | Generate a soundtrack, on models that support audio (Seedance and Together AI models with audio; Veo always includes audio) |
| `test_mode` | `Boolean` | When `true`, returns a sample video without using credits |
| `puter_output_path` | `String` | When set, the generated video is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
#### OpenAI Options
#### Choosing a model
Available when using model `sora-2` or `sora-2-pro`:
Model ids are matched case-insensitively, and every model answers to a few spellings: the fully qualified id, the `org/model` form and the bare model name. `'google:google/veo-3.1-lite'`, `'google/veo-3.1-lite'`, `'veo-3.1-lite'` and `'veo-3.1-lite-generate-preview'` all select the same model. When a bare name is served by more than one provider (`'veo-3.1'` is offered by Google directly and through Together AI) Puter picks the cheaper listing; pass `provider` or the fully qualified id to choose explicitly.
When you pass no model, Puter uses Veo 3.1 Lite on Google (`veo-3.1-lite`). A self-hosted Puter without a Google key falls back to the first provider it has a key for.
`seconds` and `size` are normalized rather than rejected: a value the model does not offer is replaced by the model default, which is the first value listed in the tables below and also what you get when you leave the option out.
#### Image inputs
`input_reference`, `last_frame` and `reference_images` take the same three formats everywhere: a public image URL, a `data:` URI, or raw base64 (treated as PNG). Providers that need inline bytes fetch URLs server-side, so a URL works on every model. What each provider does with them:
| Provider | First frame | Last frame | Reference images |
|----------|-------------|------------|------------------|
| Google Veo 3.1 | Yes | Yes | Up to 3; forces an 8 second clip; `input_reference` and `last_frame` are ignored when set |
| Together AI | Models with a `first` keyframe | Models with a `last` keyframe | Model-dependent (Seedance 2.5: up to 30) |
| BytePlus Seedance | Yes | Yes, with `input_reference` | Seedance 2.0: up to 9; Seedance 2.5: up to 30; cannot be combined with frames |
#### Google (Veo) options
Available when using a Veo 3.1 model (provider `'gemini-video-generation'`). Google has retired Veo 3.0; Veo 2.0 is offered through Together AI instead (see the next section).
| Model | Aliases | Durations (s) | Sizes | Reference images |
|-------|---------|---------------|-------|------------------|
| `veo-3.1-lite-generate-preview` (default) | `veo-3.1-lite`, `google/veo-3.1-lite` | `4`, `6`, `8` | `1280x720`, `720x1280`, `1920x1080`, `1080x1920` | Up to 3 |
| `veo-3.1-fast-generate-preview` | `veo-3.1-fast`, `google/veo-3.1-fast` | `4`, `6`, `8` | `1280x720`, `720x1280`, `1920x1080`, `1080x1920`, `3840x2160`, `2160x3840` | Up to 3 |
| `veo-3.1-generate-preview` | `veo-3.1`, `google/veo-3.1` | `4`, `6`, `8` | `1280x720`, `720x1280`, `1920x1080`, `1080x1920`, `3840x2160`, `2160x3840` | Up to 3 |
Sizes map onto Veo's aspect ratio (16:9 or 9:16) and resolution tier (720p, 1080p or 4K). 1080p and 4K clips, and any request that uses `reference_images`, are always 8 seconds long regardless of `seconds`. Veo is billed per second; 4K costs more than 720p and 1080p, and on Veo 3.1 Fast and Veo 3.1 Lite 1080p costs more than 720p. Every Veo clip comes with a generated soundtrack.
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use. Available: `'sora-2'`, `'sora-2-pro'` |
| `seconds` | `Number` | Target clip length in seconds. Available: `4`, `8`, `12` |
| `size` | `String` | Output resolution (e.g., `'720x1280'`, `'1280x720'`, `'1024x1792'`, `'1792x1024'`). `resolution` is an alias |
| `input_reference` | `File` | Optional image reference that guides generation. |
For more details about each option, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/videos/create).
#### Google (Veo) Options
Available when using a Veo model (`veo-2.0-generate-001`, `veo-3.0-generate-001`, `veo-3.1-generate-preview`, etc.):
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use. Available: `'veo-2.0-generate-001'`, `'veo-3.0-generate-001'`, `'veo-3.0-fast-generate-001'`, `'veo-3.1-generate-preview'`, `'veo-3.1-fast-generate-preview'`, `'veo-3.1-lite-generate-preview'` |
| `seconds` | `Number` | Target clip length in seconds. Veo 2.0: `5`, `6`, `8`. Veo 3.x: `4`, `6`, `8`. Note: 1080p and 4K output require `seconds: 8` |
| `size` | `String` | Output dimensions (e.g., `'1280x720'`, `'1920x1080'`, `'3840x2160'`). `resolution` is an alias. 4K sizes only available on Veo 3.1 models |
| `model` | `String` | Video model to use. Available: `'veo-3.1-lite-generate-preview'` (default), `'veo-3.1-fast-generate-preview'`, `'veo-3.1-generate-preview'` or any alias above |
| `seconds` | `Number` | Clip length: `4` (default), `6` or `8` |
| `size` | `String` | Output dimensions from the table above. Defaults to `'1280x720'`. `resolution` is an alias |
| `negative_prompt` | `String` | Text describing what to avoid in the video |
| `input_reference` | `String` | Base64 image used as the first frame (image-to-video). |
| `reference_images` | `Array<String>` | Up to 3 base64 images used as style/asset references. Supported on Veo 3.1 models only |
| `last_frame` | `String` | Base64 image used as the last frame |
| `input_reference` | `String` | Image used as the first frame (image-to-video): URL, `data:` URI or raw base64 |
| `last_frame` | `String` | Image used as the last frame, same formats. Ignored when `reference_images` is set |
| `reference_images` | `Array<String>` | Up to 3 images (same formats) the model uses as subject or style references. When set, `input_reference` and `last_frame` are ignored and the clip is 8 seconds long |
For more details, see the [Google Veo API reference](https://ai.google.dev/gemini-api/docs/video).
#### TogetherAI Options
#### Together AI options
Available when using a TogetherAI model:
Available when using any model below (provider `'together-video-generation'`). Pass the model as `'org/model'`, or prefix it with `togetherai:` to make the routing explicit; `'togetherai:google/veo-3.1'` runs Veo through Together AI while a bare `'veo-3.1'` goes to Google directly.
Together AI models are priced in one of two ways, marked in the table:
- **Per clip.** The model renders a fixed-length clip for a flat price. The request is either affordable or rejected with `insufficient_funds`.
- **Per second.** Newer models are billed per second of output at a rate that depends on the resolution. Puter estimates the cost from the highest published rate before the request, shortens the clip to fit the remaining balance (see [Cost and clip length](#cost-and-clip-length)), and then charges the exact amount Together AI reports for the finished job.
Most models size their output in pixels: pass `size` as `'WIDTHxHEIGHT'` (or `width` and `height` directly) from the combinations each model advertises in the sizes column. Seedance 2.5 and Wan 2.7 instead work in resolution tiers, so `size` picks the tier of the shorter side (or name the tier directly), and Wan 2.7 takes the aspect ratio from it. "Keyframes" says whether the model can start from an image (`first`) and also end on one (`last`); supply them through `input_reference` and `last_frame`, or through `frame_images` for finer control. "Provider default" means Together AI has not published the values: the model applies its own defaults, and a `seconds` or size it does not accept is rejected with `upstream_bad_request`.
| Model | Pricing | Duration (s) | Sizes | FPS | Keyframes | Notes |
|-------|---------|--------------|-------|-----|-----------|-------|
| `minimax/video-01-director` (default) | Per clip | 5 | `1366x768` | 25 | first | |
| `minimax/hailuo-02` | Per clip | 10 | `1366x768`, `1920x1080` | 25 | first | |
| `google/veo-2.0` | Per clip | 5 | `1280x720`, `720x1280` | 24 | first, last | |
| `google/veo-3.1` | Per second | `4`, `6`, `8` | provider default | 24 | first, last | Pass as `togetherai:google/veo-3.1`; a bare `veo-3.1` goes to Google directly |
| `google/veo-3.1-lite` | Per second | `4`, `6`, `8` | provider default | 24 | first, last | Pass as `togetherai:google/veo-3.1-lite` |
| `bytedance/seedance-1.0-lite` | Per clip | 5 | `864x480`, `736x544`, `640x640`, `960x416`, `416x960`, `1248x704`, `1120x832`, `960x960`, `1504x640`, `640x1504` | 24 | first, last | |
| `bytedance/seedance-1.0-pro` | Per clip | 5 | same as Seedance 1.0 Lite | 24 | first, last | |
| `bytedance/seedance-2.0` | Per second | 4 to 15, default 5 | provider default | 24 | first, last | Soundtrack generated by default |
| `bytedance/seedance-2.5` | Per second | 4 to 30, default 5 | `720p`, `480p` | 24 | first, last | Soundtrack always generated; up to 30 `reference_images`; no aspect ratio control |
| `pixverse/pixverse-v5` | Per clip | 5 | 360p, 540p, 720p or 1080p in 16:9, 4:3, 1:1, 3:4 or 9:16 (e.g. `1280x720`, `720x720`, `720x1280`) | 16, 24 | first, last | |
| `pixverse/pixverse-v5.6` | Per second | provider default | provider default | | | |
| `pixverse/pixverse-v6` | Per second | provider default | provider default | | | |
| `kwaivgi/kling-2.1-master` | Per clip | 5 | `1920x1080`, `1080x1080`, `1080x1920` | 24 | first | |
| `kwaivgi/kling-2.1-standard` | Per clip | 5 | `1920x1080`, `1080x1080`, `1080x1920` | 24 | first | Image-to-video: requires a first frame |
| `kwaivgi/kling-2.1-pro` | Per clip | 5 | `1920x1080`, `1080x1080`, `1080x1920` | 24 | first, last | Image-to-video: requires a first frame |
| `kwaivgi/kling-1.6-standard` | Per clip | 5 | `1920x1080`, `1080x1080`, `1080x1920` | 30, 24 | first | |
| `wan-ai/wan2.7-t2v` | Per second | 2 to 15, default 5 | `720P`, `1080P` in 16:9, 9:16, 1:1, 4:3 or 3:4 | 30 | | Soundtrack generated |
| `wan-ai/wan2.7-i2v` | Per second | 2 to 15, default 5 | as Wan 2.7 T2V | 30 | first, last | Image-to-video: requires a first frame |
| `wan-ai/wan2.7-r2v` | Per second | 2 to 10, default 5 | as Wan 2.7 T2V | 30 | | Reference-to-video: requires `reference_images` |
| `alibaba/happyhorse-1.0-t2v` | Per second | provider default | provider default | | | |
| `alibaba/happyhorse-1.0-i2v` | Per second | provider default | provider default | | first | Image-to-video: requires a first frame |
| `alibaba/happyhorse-1.0-r2v` | Per second | provider default | provider default | | | Reference-to-video: requires `reference_images` |
| `alibaba/happyhorse-1.1-t2v` | Per second | provider default | provider default | | | |
| `alibaba/happyhorse-1.1-i2v` | Per second | provider default | provider default | | first | Image-to-video: requires a first frame |
| `alibaba/happyhorse-1.1-r2v` | Per second | provider default | provider default | | | Reference-to-video: requires `reference_images` |
| `vidu/vidu-q1` | Per clip | 5 | `1920x1080`, `1080x1080`, `1080x1920` | 24 | first, last | |
| `vidu/vidu-q3` | Per second | provider default | provider default | | | |
| `vidu/vidu-q3-turbo` | Per second | provider default | provider default | | | |
| `black-forest-labs/flux-3` | Per second | provider default | provider default | | | |
| Option | Type | Description |
|--------|------|-------------|
| `width` | `Number` | Output video width in pixels |
| `model` | `String` | Video model to use, from the table above |
| `size` | `String` | `'WIDTHxHEIGHT'` from the sizes column, or a tier (`'720p'`, `'480p'`, `'720P'`, `'1080P'`) for Seedance 2.5 and Wan 2.7. `resolution` is an alias |
| `width` | `Number` | Output video width in pixels. On Wan 2.7, `width` and `height` only select the aspect ratio |
| `height` | `Number` | Output video height in pixels |
| `fps` | `Number` | Frames per second |
| `fps` | `Number` | Frames per second, where the model offers a choice |
| `steps` | `Number` | Number of inference steps |
| `guidance_scale` | `Number` | How closely to follow the prompt |
| `seed` | `Number` | Random seed for reproducible results |
| `output_format` | `String` | Output format for the video |
| `output_quality` | `Number` | Quality level of the output |
| `negative_prompt` | `String` | Text describing what to avoid in the video |
| `generate_audio` | `Boolean` | Generate a soundtrack, on models that support audio |
| `input_reference` | `String` | First-frame image (URL, `data:` URI or base64); sent as the model's first keyframe |
| `last_frame` | `String` | Last-frame image, same formats; sent as the model's last keyframe |
| `reference_images` | `Array<String>` | Reference images to guide the generation |
| `frame_images` | `Array<Object>` | Frame images for video-to-video generation. Each object has `input_image` (`String` - image URL) and `frame` (`Number` - frame index) |
| `frame_images` | `Array<Object>` | Explicit keyframes, overriding `input_reference` and `last_frame`. Each object has `input_image` (`String`) and `frame` (`Number`, the frame position the image anchors; `0` is the first frame). One image is the first frame; two are the first and last |
| `metadata` | `Object` | Additional metadata for the request |
For more details about each option, see the [TogetherAI API reference](https://docs.together.ai/reference/create-videos).
For more details about each option, see the [Together AI API reference](https://docs.together.ai/reference/create-videos).
#### BytePlus (Seedance) options
Available when using a Seedance model served by BytePlus ModelArk (provider `'byteplus-video-generation'`). These models take any whole number of seconds within their range and work in resolution tiers: pass `size` as a tier or as `'WIDTHxHEIGHT'`, which selects the tier of the shorter side and the aspect ratio.
| Model | Alias | Duration (s) | Resolutions | Audio | Last frame | Reference images | Seed |
|-------|-------|--------------|-------------|-------|------------|------------------|------|
| `dreamina-seedance-2-5-260628` | `seedance-2-5` | 4 to 30, default 5 | `720p`, `480p`, `1080p` | Yes | Yes | Up to 30 | No |
| `dreamina-seedance-2-0-260128` | `seedance-2-0` | 4 to 15, default 5 | `720p`, `480p`, `1080p`, `4k` | Yes | Yes | Up to 9 | No |
| `dreamina-seedance-2-0-fast-260128` | `seedance-2-0-fast` | 4 to 15, default 5 | `720p`, `480p` | Yes | Yes | Up to 9 | No |
| `dreamina-seedance-2-0-mini-260615` (default) | `seedance-2-0-mini` | 4 to 15, default 5 | `720p`, `480p` | Yes | Yes | Up to 9 | No |
| `seedance-1-5-pro-251215` | `seedance-1-5-pro` | 4 to 12, default 5 | `720p`, `480p`, `1080p` | Yes | Yes | No | Yes |
| `seedance-1-0-pro-250528` | `seedance-1-0-pro` | 2 to 12, default 5 | `1080p`, `480p`, `720p` | No | Yes | No | Yes |
| `seedance-1-0-pro-fast-251015` | `seedance-1-0-pro-fast` | 2 to 12, default 5 | `1080p`, `480p`, `720p` | No | No | No | Yes |
The first resolution listed is the default. Seedance is billed per video token, and the token count grows with duration and pixel count, so a 4K clip costs several times more per second than 720p. Generated clips carry no watermark.
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Video model to use, from the table above (full id or alias) |
| `seconds` | `Number` | Clip length, any whole number of seconds in the model's range. Out-of-range values fall back to `5` |
| `size` | `String` | Resolution tier: `'480p'`, `'720p'`, `'1080p'` or `'4k'` (case-insensitive) from the table above, or `'WIDTHxHEIGHT'`. `resolution` is an alias |
| `width`, `height` | `Number` | Optional. Only their ratio is used, to pick the output aspect ratio: `16:9`, `4:3`, `1:1`, `3:4`, `9:16` or `21:9`. Any other ratio is ignored and the model chooses |
| `generate_audio` | `Boolean` | Generate a soundtrack along with the video. Defaults to `true` on models that support audio. Set `false` for a silent clip, which on Seedance 1.5 Pro is also cheaper |
| `seed` | `Number` | Random seed for reproducible results. Seedance 1.x only |
| `input_reference` | `String` | Image used as the first frame (image-to-video): a public image URL or a `data:` URI |
| `last_frame` | `String` | Image used as the last frame, same formats. Requires `input_reference` |
| `reference_images` | `Array<String>` | Images (URL or `data:` URI) the model uses as subject or style references: up to 9 on the Seedance 2.0 models, up to 30 on Seedance 2.5. Cannot be combined with `input_reference` or `last_frame` |
An invalid combination (a last frame without a first frame, reference images together with a first frame, or an option the model lacks) is rejected with `bad_request` before any credits are spent.
For more details, see the [BytePlus ModelArk video generation reference](https://docs.byteplus.com/en/docs/ModelArk/1520757).
Any properties not set fall back to provider defaults.
@@ -100,32 +200,59 @@ puter.ai.txt2vid("A drone shot over a forest", {
Absolute paths (`/username/Videos/forest.mp4`) and home-relative paths (`~/Videos/forest.mp4`) are sent as-is. Write permission to the destination is enforced server-side.
The destination is checked before generation starts, so a path you cannot write to fails immediately with `access_denied` and costs nothing. Missing parent folders are created and an existing file at the path is overwritten. Most providers return a temporary hosted URL (see [Return value](#return-value)), so this is the way to keep a clip after the request completes.
#### Cost and clip length
Every successful generation is charged to the user's AI credits according to the model, duration and resolution. Before contacting the provider, Puter compares the estimated cost with the remaining balance:
- **Veo and Seedance** are priced per second. If the balance cannot cover the requested length, Puter shortens the clip to the longest duration the model supports that the balance does cover, and rejects with `insufficient_funds` only when even the shortest clip is unaffordable. The response does not flag a shortened clip, so read `video.duration` once metadata has loaded if the exact length matters. Veo clips at 1080p or 4K, or with `reference_images`, are fixed at 8 seconds and therefore all-or-nothing.
- **Together AI** prices its older models per clip, so those requests are either accepted at the model's duration or rejected with `insufficient_funds`. Its per-second models (marked in the Together AI table) are shortened like Veo and Seedance, and the final charge is the amount Together AI reports for the job.
A request that fails or times out is not charged. The request and concurrency limits that apply to every AI call are listed in [Rate limits and quotas](/rate-limits-and-quotas/).
#### How long it takes
Video generation is slow: expect anywhere from tens of seconds to several minutes, growing with duration and resolution. The returned promise stays pending until the clip is ready, so keep the UI responsive with a progress indicator (see the examples below). Puter waits up to ten minutes for a job; one that outlives the window fails with `upstream_timeout`.
## Return value
A `Promise` that resolves to an `HTMLVideoElement`. The element is preloaded, has `controls` enabled, and exposes metadata via `data-mime-type` and `data-source` attributes. Append it to the DOM to display the generated clip immediately.
A `Promise` that resolves to an `HTMLVideoElement` (in browsers) that you can append to the DOM straight away:
> **Note:** Video generation can take several minutes to complete. The returned promise resolves only when the video is ready, so keep your UI responsive (for example, by showing a spinner) while you wait. Each successful generation consumes the users AI credits in accordance with the model, duration, and resolution you request.
- `src` is the clip's URL. Depending on the provider it is either an `https:` URL on the provider's storage (Together AI, Seedance, and usually Veo) or a `data:` URI holding the whole clip (Veo, when Google returns the bytes inline). Provider URLs are temporary; keep a copy with `puter_output_path` if you need the clip later.
- `controls` is enabled and `preload` is `"metadata"`.
- The `data-source` attribute carries the same URL, and `data-mime-type` the MIME type when it is known (for example `video/mp4`).
- `String(video)` returns the URL, so the element can be dropped into a template or passed to `fetch()`.
In Node.js and Workers, where there is no DOM, the promise resolves to a plain object with the same `src`, `controls` and `preload` fields and the same `toString()`; the `data-*` attributes are not present. Use `fetch(video.src)` to read the bytes, or `puter_output_path` to have Puter store the file for you.
In test mode `src` is always the `https:` URL of Puter's sample clip.
> **Note:** Each successful generation consumes the user's AI credits in accordance with the model, duration and resolution you request. See [Cost and clip length](#cost-and-clip-length) for how Puter fits a clip to the remaining balance.
## Errors
A rejection carries the error body exactly as the backend sent it. Every error has `message` and `code`; the other fields appear when they apply.
A rejection carries the error body exactly as the backend sent it, or `{ message, code }` for the checks the SDK runs before making the request. Every error has `message` and `code`; the other fields appear when they apply.
| Field | Meaning |
| --- | --- |
| `message` | Human-readable reason. `error` carries the same text for older clients. |
| `code` | Stable error code; see the table below. |
| `errorCode` | A more specific code alongside a general `code`. Today the only value is `moderation_flagged`. |
| `provider` | Which upstream handled the request: `gemini` (Veo), `together`, `byteplus` or `openai`. Present on errors raised while a job was running. |
| `provider` | Which upstream handled the request: `gemini` (Veo), `together` or `byteplus`. Present on errors raised while a job was running. |
| `upstreamCode` | The provider's own error code, when it gave one. |
| `upstreamStatus` | The HTTP status the provider returned, when it rejected the request before a job started. |
| Code | Meaning |
| --- | --- |
| `upstream_timeout` | The provider did not finish the clip within the time Puter waits for it, or stopped answering. Puter waits ten minutes for Veo, Together and BytePlus models and five minutes for Sora models. Arrives as HTTP 504. The request itself was fine; retry it, ideally with a shorter clip or a faster model. |
| `errorCode: moderation_flagged` | The provider's content filter refused the prompt or removed the generated video. Arrives as HTTP 400, with `code: bad_request` from Together and BytePlus and `code: disallowed_value` from Veo. Change the prompt rather than retrying it as-is. Sora does not report refusals distinctly; they arrive as `upstream_failed`. |
| `prompt_required` | Raised by the SDK before any request is made: the call had no prompt. |
| `bad_request` (without `errorCode`) | Puter rejected the request before contacting a provider: an unknown `model` (`Model not found: …`), an invalid combination of image inputs, or an image URL that could not be fetched. `message` says which. Arrives as HTTP 400. |
| `access_denied` | `puter_output_path` points somewhere the caller may not write. Arrives as HTTP 403, before any credits are spent. `cannot_write_to_root` (HTTP 400) is the same check for a path directly under `/`. |
| `upstream_timeout` | The provider did not finish the clip within the ten minutes Puter waits for it, or stopped answering. Arrives as HTTP 504. The request itself was fine; retry it, ideally with a shorter clip or a faster model. |
| `errorCode: moderation_flagged` | The provider's content filter refused the prompt or removed the generated video. Arrives as HTTP 400, with `code: bad_request` from Together and BytePlus and `code: disallowed_value` from Veo. Change the prompt rather than retrying it as-is. |
| `upstream_bad_request` | The provider rejected the request itself, for example a duration the model does not support. Arrives as HTTP 400; `message` and `upstreamCode` carry the provider's reason. |
| `upstream_failed` | The provider accepted the request but generation failed on their side. From Veo, Together and BytePlus it arrives as HTTP 502 and is safe to retry. From Sora it arrives as HTTP 400 and may also be a content-policy refusal, so read the `message` before retrying the same prompt. |
| `insufficient_funds` | Your balance cannot cover the estimated cost of the clip. Arrives as HTTP 402. |
| `upstream_failed` | The provider accepted the request but generation failed on their side. Arrives as HTTP 502 and is safe to retry. |
| `insufficient_funds` | Your balance cannot cover even the shortest clip the model offers (or, for a per-clip Together AI model, the clip). Arrives as HTTP 402; `message` states the shortfall. |
Other `upstream_*` codes mean the provider rejected the request or was unavailable before a job started; `message` carries the provider's reason.
@@ -149,7 +276,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</html>
```
<strong class="example-title">Generate an 8-second cinematic clip</strong>
<strong class="example-title">Generate an 8-second cinematic clip in 1080p</strong>
```html;ai-txt2vid-options
<html>
@@ -157,9 +284,9 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.txt2vid("A fox sprinting through a snow-covered forest at dusk", {
model: "sora-2-pro",
model: "veo-3.1-fast",
seconds: 8,
size: "1280x720"
size: "1920x1080"
}).then((video) => {
document.body.appendChild(video);
// Autoplay once metadata is available
@@ -169,3 +296,148 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab
</body>
</html>
```
<strong class="example-title">Use a Google Veo model with a negative prompt</strong>
```html;ai-txt2vid-veo
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.txt2vid("A hummingbird hovering over a red flower, macro lens, soft morning light", {
model: "veo-3.1-fast",
seconds: 6,
size: "1280x720",
negative_prompt: "blurry, text, watermark, people"
}).then((video) => {
document.body.appendChild(video);
}).catch(console.error);
</script>
</body>
</html>
```
<strong class="example-title">Animate a photo (image-to-video)</strong>
```html;ai-txt2vid-image-to-video
<html>
<body>
<p>Pick a photo to animate:</p>
<input type="file" id="photo" accept="image/*">
<p id="status"></p>
<script src="https://js.puter.com/v2/"></script>
<script>
const status = document.getElementById('status');
document.getElementById('photo').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// Seedance and Veo take the first frame as a data: URI
const dataUri = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
status.textContent = 'Generating... this can take a few minutes.';
try {
const video = await puter.ai.txt2vid("Slow push-in on the scene as a gentle wind moves through it", {
model: "seedance-2-0-mini",
seconds: 5,
size: "720p",
input_reference: dataUri,
generate_audio: false
});
status.textContent = '';
document.body.appendChild(video);
} catch (err) {
status.textContent = `Failed: ${err.message} (${err.code})`;
}
});
</script>
</body>
</html>
```
<strong class="example-title">Save the clip to the Puter filesystem</strong>
```html;ai-txt2vid-save
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
// test_mode keeps this free; the sample clip is still written to the path
puter.ai.txt2vid({
prompt: "A paper boat drifting down a rain-soaked street",
test_mode: true,
puter_output_path: "videos/paper-boat.mp4" // ~/AppData/<appID>/videos/paper-boat.mp4
}).then(async (video) => {
const file = await puter.fs.stat("videos/paper-boat.mp4");
puter.print(`Saved ${file.name} (${file.size} bytes) to ${file.path}`);
document.body.appendChild(video);
}).catch(console.error);
</script>
</body>
</html>
```
<strong class="example-title">Show progress and handle errors</strong>
```html;ai-txt2vid-errors
<html>
<body>
<button id="go">Generate</button>
<p id="status"></p>
<script src="https://js.puter.com/v2/"></script>
<script>
const status = document.getElementById('status');
document.getElementById('go').addEventListener('click', async () => {
const started = Date.now();
const ticker = setInterval(() => {
status.textContent = `Generating... ${Math.round((Date.now() - started) / 1000)}s`;
}, 1000);
try {
// No model given: the default Veo 3.1 Lite is used
const video = await puter.ai.txt2vid("A lighthouse in a storm, waves crashing, dramatic lighting", {
seconds: 4
});
status.textContent = 'Done';
document.body.appendChild(video);
} catch (err) {
if (err.errorCode === 'moderation_flagged') {
status.textContent = 'The content filter refused this prompt. Try rewording it.';
} else if (err.code === 'insufficient_funds') {
status.textContent = 'Not enough credits for this clip.';
} else if (err.code === 'upstream_timeout') {
status.textContent = 'The provider took too long. Try again with a shorter clip.';
} else {
status.textContent = `Failed: ${err.message} (${err.code})`;
}
} finally {
clearInterval(ticker);
}
});
</script>
</body>
</html>
```
<strong class="example-title">Generate a clip from Node.js</strong>
```js
import { init } from "@heyputer/puter.js/src/init.cjs";
const puter = init(process.env.puterAuthToken);
const video = await puter.ai.txt2vid("Time-lapse of clouds rolling over a mountain lake", {
seconds: 6,
puter_output_path: "~/Videos/clouds.mp4",
});
// There is no <video> element outside a browser; the result still exposes the URL.
console.log(String(video));
// The clip is also saved at ~/Videos/clouds.mp4 in the user's Puter filesystem.
```
+24
View File
@@ -265,6 +265,30 @@ const examples = [
slug: 'ai-txt2vid-options',
source: '/playground/examples/ai-txt2vid-options.html',
},
{
title: 'Text to Video with Google Veo',
description: 'Generate a video with a Google Veo model and a negative prompt using Puter.js AI API. Run and experiment with this example in the playground.',
slug: 'ai-txt2vid-veo',
source: '/playground/examples/ai-txt2vid-veo.html',
},
{
title: 'Image to Video',
description: 'Animate a photo into a short clip with Puter.js AI API by passing it as the first-frame reference image. Run and experiment with this example in the playground.',
slug: 'ai-txt2vid-image-to-video',
source: '/playground/examples/ai-txt2vid-image-to-video.html',
},
{
title: 'Save a generated video to the Puter filesystem',
description: 'Generate a video and store it directly in the Puter filesystem with puter_output_path. Run and experiment with this example in the playground.',
slug: 'ai-txt2vid-save',
source: '/playground/examples/ai-txt2vid-save.html',
},
{
title: 'Text to Video with progress and error handling',
description: 'Show progress while a video renders and handle moderation, credit and timeout errors with Puter.js AI API. Run and experiment with this example in the playground.',
slug: 'ai-txt2vid-errors',
source: '/playground/examples/ai-txt2vid-errors.html',
},
{
title: 'List AI models',
description: 'Retrieve the available AI chat models (and providers) in Puter.js. Try out this example directly in the playground.',
@@ -0,0 +1,38 @@
<html>
<body>
<button id="go">Generate</button>
<p id="status"></p>
<script src="https://js.puter.com/v2/"></script>
<script>
const status = document.getElementById('status');
document.getElementById('go').addEventListener('click', async () => {
const started = Date.now();
const ticker = setInterval(() => {
status.textContent = `Generating... ${Math.round((Date.now() - started) / 1000)}s`;
}, 1000);
try {
// No model given: the default Veo 3.1 Lite is used
const video = await puter.ai.txt2vid("A lighthouse in a storm, waves crashing, dramatic lighting", {
seconds: 4
});
status.textContent = 'Done';
document.body.appendChild(video);
} catch (err) {
if (err.errorCode === 'moderation_flagged') {
status.textContent = 'The content filter refused this prompt. Try rewording it.';
} else if (err.code === 'insufficient_funds') {
status.textContent = 'Not enough credits for this clip.';
} else if (err.code === 'upstream_timeout') {
status.textContent = 'The provider took too long. Try again with a shorter clip.';
} else {
status.textContent = `Failed: ${err.message} (${err.code})`;
}
} finally {
clearInterval(ticker);
}
});
</script>
</body>
</html>
@@ -0,0 +1,39 @@
<html>
<body>
<p>Pick a photo to animate:</p>
<input type="file" id="photo" accept="image/*">
<p id="status"></p>
<script src="https://js.puter.com/v2/"></script>
<script>
const status = document.getElementById('status');
document.getElementById('photo').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// Seedance and Veo take the first frame as a data: URI
const dataUri = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
status.textContent = 'Generating... this can take a few minutes.';
try {
const video = await puter.ai.txt2vid("Slow push-in on the scene as a gentle wind moves through it", {
model: "seedance-2-0-mini",
seconds: 5,
size: "720p",
input_reference: dataUri,
generate_audio: false
});
status.textContent = '';
document.body.appendChild(video);
} catch (err) {
status.textContent = `Failed: ${err.message} (${err.code})`;
}
});
</script>
</body>
</html>
@@ -3,9 +3,9 @@
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.txt2vid("A fox sprinting through a snow-covered forest at dusk", {
model: "sora-2-pro",
model: "veo-3.1-fast",
seconds: 8,
size: "1280x720"
size: "1920x1080"
}).then((video) => {
document.body.appendChild(video);
// Autoplay once metadata is available
@@ -13,4 +13,4 @@
}).catch(console.error);
</script>
</body>
</html>
</html>
@@ -0,0 +1,17 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
// test_mode keeps this free; the sample clip is still written to the path
puter.ai.txt2vid({
prompt: "A paper boat drifting down a rain-soaked street",
test_mode: true,
puter_output_path: "videos/paper-boat.mp4" // ~/AppData/<appID>/videos/paper-boat.mp4
}).then(async (video) => {
const file = await puter.fs.stat("videos/paper-boat.mp4");
puter.print(`Saved ${file.name} (${file.size} bytes) to ${file.path}`);
document.body.appendChild(video);
}).catch(console.error);
</script>
</body>
</html>
@@ -0,0 +1,15 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.txt2vid("A hummingbird hovering over a red flower, macro lens, soft morning light", {
model: "veo-3.1-fast",
seconds: 6,
size: "1280x720",
negative_prompt: "blurry, text, watermark, people"
}).then((video) => {
document.body.appendChild(video);
}).catch(console.error);
</script>
</body>
</html>
+30 -16
View File
@@ -228,29 +228,43 @@
* Options for `txt2vid()`.
*
* @typedef {Object} Txt2VidOptions
* @property {string} [prompt]
* @property {string} [driver]
* @property {string} [model]
* @property {number} [seconds]
* @property {number} [duration]
* @property {boolean} [test_mode]
* @property {string} [size] OpenAI: output size.
* @property {string} [resolution] OpenAI: output resolution.
* @property {File | string} [input_reference] OpenAI: reference clip or image.
* @property {number} [width] TogetherAI.
* @property {number} [height] TogetherAI.
* @property {string} [prompt] Text description of the clip.
* @property {string} [provider] Pin the request to one provider: `'gemini-video-generation'`,
* `'together-video-generation'` or `'byteplus-video-generation'`. Defaults to the provider that owns
* `model`, or Google when neither is given.
* @property {string} [driver] Same effect as `provider`.
* @property {string} [model] Video model id (provider-specific). Defaults to `'veo-3.1-lite'`.
* @property {number} [seconds] Clip length in seconds. A value the model does not offer falls back
* to the model default.
* @property {number} [duration] Alias of `seconds`.
* @property {boolean} [test_mode] When `true`, returns a sample clip without using credits.
* @property {string} [size] Output size as `'WIDTHxHEIGHT'` on every provider (tier-based models map
* it to the tier of the shorter side plus an aspect ratio), or a tier such as `'720p'` for Seedance
* and Wan 2.7.
* @property {string} [resolution] Alias of `size`.
* @property {string} [input_reference] First-frame image for image-to-video: a URL, data URI or raw
* base64, on every provider.
* @property {string} [last_frame] Last-frame image, same formats as `input_reference`.
* @property {string[]} [reference_images] Subject/style reference images (URL, data URI or base64).
* Veo 3.1: up to 3; Seedance 2.0: up to 9; Seedance 2.5: up to 30; Together: model-dependent.
* @property {string} [negative_prompt] What to keep out of the video (Veo, Together).
* @property {boolean} [generate_audio] Generate a soundtrack on models that support audio (Seedance 2.x
* and 1.5 Pro, and Together models with audio). Defaults to `true` on Seedance.
* @property {number} [seed] Random seed (Together, Seedance 1.x).
* @property {number} [width] Output width in pixels on Together models sized in pixels; with `height`,
* selects the aspect ratio on Seedance and Wan 2.7. Filled in from `size` when omitted.
* @property {number} [height] Output height in pixels; see `width`.
* @property {number} [fps] TogetherAI.
* @property {number} [steps] TogetherAI.
* @property {number} [guidance_scale] TogetherAI.
* @property {number} [seed] TogetherAI.
* @property {string} [output_format] TogetherAI.
* @property {number} [output_quality] TogetherAI.
* @property {string} [negative_prompt] TogetherAI.
* @property {string[]} [reference_images] TogetherAI.
* @property {Array<{ input_image: string, frame: number }>} [frame_images] TogetherAI.
* @property {Array<{ input_image: string, frame: number }>} [frame_images] TogetherAI: keyframe images
* for image-to-video.
* @property {Record<string, unknown>} [metadata] TogetherAI.
* @property {string} [puter_output_path] Save the generated video to this path on the Puter filesystem.
* @property {string} [last_frame] Final frame to guide generation toward.
* Relative paths resolve against the app's data directory (`~/AppData/<appID>/`) when called from an
* app, or `~/` otherwise. The caller must have write permission to the destination.
*/
/**
+2 -2
View File
@@ -340,7 +340,7 @@ export const transformToV2 = (source) => {
'xai', 'openrouter', 'together-ai', 'ollama',
'elevenlabs', 'aws-polly', 'aws-textract', 'mistral-ocr', 'cloudflare',
'openai-completion', 'openai-responses',
'openai-image-generation', 'openai-video-generation',
'openai-image-generation',
'gemini-image-generation', 'gemini-video-generation',
'together-image-generation', 'together-video-generation',
'cloudflare-image-generation', 'xai-image-generation',
@@ -389,7 +389,7 @@ export const transformToV2 = (source) => {
// (e.g. `openai-completion`, `openai-image-generation`), so seed each
// split id from the base id when the split key isn't already set.
const FAN_OUT = {
openai: ['openai-completion', 'openai-responses', 'openai-image-generation', 'openai-video-generation'],
openai: ['openai-completion', 'openai-responses', 'openai-image-generation'],
gemini: ['gemini-image-generation', 'gemini-video-generation'],
'together-ai': ['together-image-generation', 'together-video-generation'],
xai: ['xai-image-generation'],