mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-03 20:43:22 +00:00
feat(ai): MiniMax video generation (H3 and Hailuo)
Adds a minimax-video-generation provider to the video driver. H3 models go through MiniMax's v2 task API and are billed per second of output; Hailuo models go through the v1 API, exchange a file id for a download URL, and are billed per clip. The shared `minimax` chat key is reused, with the chat block's `/v1` base URL reduced to its host. WIDTHxHEIGHT sizes now map onto tiers for catalogs that include a 2k or 4k tier. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
acd3df8c1b
commit
0b4774c90c
11 files changed
+2045
-12
No files matched your search
@@ -433,6 +433,8 @@
|
||||
"mistral": { "apiKey": "" },
|
||||
"xai": { "apiKey": "" },
|
||||
"moonshot": { "apiKey": "" },
|
||||
// MiniMax. One key powers chat and video generation (H3, Hailuo);
|
||||
// video calls go to the same host, beside the chat API's `/v1`.
|
||||
"minimax": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.minimax.io/v1"
|
||||
|
||||
@@ -103,6 +103,11 @@ beforeAll(async () => {
|
||||
// block, so this one alone must not register a provider.
|
||||
'gemini-video-generation': { apiKey: 'gem-key' },
|
||||
kling: { apiKey: 'kling-key' },
|
||||
// The chat block's base URL ends in `/v1`; video must drop it.
|
||||
minimax: {
|
||||
apiKey: 'mm-key',
|
||||
apiBaseUrl: 'https://api.minimax.io/v1',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
driver = server.drivers.aiVideo as unknown as VideoGenerationDriver;
|
||||
@@ -507,6 +512,54 @@ describe('VideoGenerationDriver.generate provider routing', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'minimax-h3',
|
||||
'minimax/minimax-h3',
|
||||
'minimax:minimax/minimax-h3',
|
||||
])('routes %s to the MiniMax provider from the shared minimax key', async (model) => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ task_id: 'mm-task' })),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
task: {
|
||||
id: 'mm-task',
|
||||
status: 'succeeded',
|
||||
content: { url: 'https://minimax/out.mp4' },
|
||||
usage: { output_seconds: 5 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await withActor(() =>
|
||||
driver.generate({ prompt: 'hi', model } as never),
|
||||
);
|
||||
|
||||
expect(result).toBe('https://minimax/out.mp4');
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
|
||||
'https://api.minimax.io/v2/video_generation',
|
||||
);
|
||||
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists the MiniMax models under the minimax-video-generation provider', async () => {
|
||||
const minimax = (await driver.models()).filter(
|
||||
(m) => m.provider === 'minimax-video-generation',
|
||||
);
|
||||
expect(minimax.map((m) => m.id).sort()).toEqual([
|
||||
'minimax-h3',
|
||||
'minimax-h3-highspeed',
|
||||
'minimax-h3-lite',
|
||||
'minimax-h3-max',
|
||||
'minimax-hailuo-02',
|
||||
'minimax-hailuo-2.3',
|
||||
'minimax-hailuo-2.3-fast',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes a known togetherai:minimax/video-01-director id to the Together provider', async () => {
|
||||
togetherVideosCreateMock.mockResolvedValueOnce({ id: 'tg-job' });
|
||||
togetherVideosRetrieveMock.mockResolvedValueOnce({
|
||||
@@ -981,6 +1034,77 @@ describe('VideoGenerationDriver.generate size unification', () => {
|
||||
expect('ratio' in sent).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a catalog with a 2k or 4k tier as tier-based', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: { id: 'kl-task', status: 'submitted' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
id: 'kl-task',
|
||||
status: 'succeeded',
|
||||
outputs: [
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://kling/out.mp4',
|
||||
duration: '5',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'kling-3.0',
|
||||
size: '1080x1920',
|
||||
} as never),
|
||||
);
|
||||
const sent = JSON.parse(
|
||||
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
|
||||
);
|
||||
expect(sent.settings.resolution).toBe('1080p');
|
||||
expect(sent.settings.aspect_ratio).toBe('9:16');
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ task_id: 'mm-task' })),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
task: {
|
||||
id: 'mm-task',
|
||||
status: 'succeeded',
|
||||
content: { url: 'https://minimax/out.mp4' },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
prompt: 'hi',
|
||||
model: 'minimax-h3-lite',
|
||||
size: '854x480',
|
||||
} as never),
|
||||
);
|
||||
const sentToMiniMax = JSON.parse(
|
||||
(fetchSpy.mock.calls[2]![1] as RequestInit).body as string,
|
||||
);
|
||||
expect(sentToMiniMax.resolution).toBe('480P');
|
||||
expect(sentToMiniMax.ratio).toBe('16:9');
|
||||
});
|
||||
|
||||
it('fills width/height from a WIDTHxHEIGHT size for pixel-sized models', async () => {
|
||||
completeTogetherJob();
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { readProviderKey } from '../util/providerRegistry.js';
|
||||
import { BytePlusVideoProvider } from './providers/byteplus/BytePlusVideoProvider.js';
|
||||
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
|
||||
import { KlingVideoProvider } from './providers/kling/KlingVideoProvider.js';
|
||||
import { MiniMaxVideoProvider } from './providers/minimax/MiniMaxVideoProvider.js';
|
||||
import { TogetherVideoProvider } from './providers/together/TogetherVideoProvider.js';
|
||||
import type {
|
||||
IGenerateVideoParams,
|
||||
@@ -50,7 +51,9 @@ import type {
|
||||
|
||||
const DEFAULT_PROVIDER = 'together-video-generation';
|
||||
|
||||
const isResolutionTier = (value: string): boolean => /^\d{3,4}p$/i.test(value);
|
||||
/** A resolution tier such as `720p`, `2k` or `4k`, as opposed to pixels. */
|
||||
const isResolutionTier = (value: string): boolean =>
|
||||
/^(\d{3,4}p|\dk)$/i.test(value);
|
||||
|
||||
const parsePixelSize = (
|
||||
value: string | undefined,
|
||||
@@ -81,7 +84,7 @@ const tierForPixels = ({
|
||||
* Driver implementing the `puter-video-generation` interface.
|
||||
*
|
||||
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini,
|
||||
* Kling) and handles model resolution, provider routing, and parameter
|
||||
* Kling, MiniMax) and handles model resolution, provider routing, and parameter
|
||||
* normalisation. Each provider is a plain `IVideoProvider` -- the driver
|
||||
* instantiates them from config on boot.
|
||||
*
|
||||
@@ -98,6 +101,7 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
'byteplus-video-generation',
|
||||
'gemini-video-generation',
|
||||
'kling-video-generation',
|
||||
'minimax-video-generation',
|
||||
];
|
||||
readonly isDefault = true;
|
||||
|
||||
@@ -468,6 +472,26 @@ export class VideoGenerationDriver extends PuterDriver {
|
||||
{ stores: this.stores, fs: this.services.fs },
|
||||
);
|
||||
}
|
||||
|
||||
// Falls back to the shared `minimax` (ai-chat) key. The chat block's
|
||||
// `apiBaseUrl` names the chat API under `/v1`; the provider keeps its
|
||||
// host, since the video API lives beside it.
|
||||
const minimaxVideoCfg = providers['minimax-video-generation'] as
|
||||
Record<string, unknown> | undefined;
|
||||
const minimaxSharedCfg = providers['minimax'] as
|
||||
Record<string, unknown> | undefined;
|
||||
const minimaxKey = readProviderKey(minimaxVideoCfg, minimaxSharedCfg);
|
||||
if (minimaxKey) {
|
||||
this.#providers['minimax-video-generation'] =
|
||||
new MiniMaxVideoProvider(
|
||||
{
|
||||
apiKey: minimaxKey,
|
||||
apiBaseUrl: (minimaxVideoCfg?.apiBaseUrl ??
|
||||
minimaxSharedCfg?.apiBaseUrl) as string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Model map -----------------------------------------------------------
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the MiniMax video provider.
|
||||
*
|
||||
* Generates a 4s 480P clip on minimax-h3-lite ($0.10). Skipped when
|
||||
* `PUTER_TEST_AI_MINIMAX_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { MiniMaxVideoProvider } from './MiniMaxVideoProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_MINIMAX_API_KEY';
|
||||
// Task-based generation can take a couple of minutes.
|
||||
const VIDEO_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'MiniMaxVideoProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates a video URL from minimax-h3-lite',
|
||||
{ timeout: VIDEO_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = new MiniMaxVideoProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'minimax-h3-lite',
|
||||
prompt: 'a red ball rolls to the right',
|
||||
resolution: '480p',
|
||||
seconds: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(typeof url).toBe('string');
|
||||
expect((url as string).startsWith('https://')).toBe(true);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,855 @@
|
||||
/*
|
||||
* 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 MiniMaxVideoProvider.
|
||||
*
|
||||
* Boots a real PuterServer and constructs the provider against the live wired
|
||||
* `MeteringService`. MiniMax's task APIs are called through global `fetch`,
|
||||
* which is stubbed as the network boundary.
|
||||
*/
|
||||
|
||||
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 { withAiCostFactor } from '../../../util/aiCostFactor.js';
|
||||
import type { IGenerateVideoParams } from '../../types.js';
|
||||
import { MiniMaxVideoProvider } from './MiniMaxVideoProvider.js';
|
||||
import { MINIMAX_VIDEO_GENERATION_MODELS } from './models.js';
|
||||
|
||||
// -- Test harness ----------------------------------------------------
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let remainingUsageSpy: MockInstance<MeteringService['getRemainingUsage']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const aiMetering = () =>
|
||||
withAiCostFactor(
|
||||
server.services.metering,
|
||||
server.clients.event,
|
||||
'ai-video',
|
||||
);
|
||||
|
||||
const makeProvider = (config: { apiKey?: string; apiBaseUrl?: string } = {}) =>
|
||||
new MiniMaxVideoProvider(
|
||||
{
|
||||
apiKey: config.apiKey ?? 'test-key',
|
||||
...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}),
|
||||
pollIntervalMs: 1,
|
||||
},
|
||||
aiMetering(),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
remainingUsageSpy = vi.spyOn(server.services.metering, 'getRemainingUsage');
|
||||
remainingUsageSpy.mockResolvedValue(Number.MAX_SAFE_INTEGER);
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
incrementUsageSpy.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const ok = { status_code: 0, status_msg: 'success' };
|
||||
|
||||
const h3Task = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'task-1',
|
||||
model: 'MiniMax-H3',
|
||||
status: 'succeeded',
|
||||
content: { url: 'https://minimax.example/out.mp4' },
|
||||
resolution: '768P',
|
||||
duration: 5,
|
||||
usage: { output_seconds: 5 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Queue the v2 create response followed by task query responses. */
|
||||
const mockH3Flow = (...polled: unknown[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task_id: 'task-1' }));
|
||||
for (const task of polled) {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task }));
|
||||
}
|
||||
};
|
||||
|
||||
const hailuoTask = (status: string, fileId = 'file-1') => ({
|
||||
task_id: 'task-1',
|
||||
status,
|
||||
file_id: status === 'Success' ? fileId : '',
|
||||
base_resp: ok,
|
||||
});
|
||||
|
||||
/** Queue the v1 create, query and file retrieve responses. */
|
||||
const mockHailuoFlow = (...polled: unknown[]) => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
for (const task of polled) {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(task));
|
||||
}
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
file: {
|
||||
file_id: 1,
|
||||
download_url: 'https://minimax.example/hailuo.mp4',
|
||||
},
|
||||
base_resp: ok,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const sentUrl = (callIndex = 0) => String(fetchSpy.mock.calls[callIndex]![0]);
|
||||
|
||||
const sentBody = (callIndex = 0): Record<string, any> =>
|
||||
JSON.parse(
|
||||
(fetchSpy.mock.calls[callIndex]![1] as RequestInit).body as string,
|
||||
);
|
||||
|
||||
const generate = (params: Partial<IGenerateVideoParams>) =>
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'a ball rolls', ...params }),
|
||||
);
|
||||
|
||||
const rate = (id: string, key: string) =>
|
||||
MINIMAX_VIDEO_GENERATION_MODELS.find((m) => m.id === id)!.costs![key]!;
|
||||
|
||||
// -- Construction / catalog ------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider construction and catalog', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() => new MiniMaxVideoProvider({ apiKey: '' }, aiMetering()),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('defaults to MiniMax H3 and lists the seven models', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('minimax-h3');
|
||||
expect(provider.models().map((m) => m.id)).toEqual([
|
||||
'minimax-h3',
|
||||
'minimax-h3-highspeed',
|
||||
'minimax-h3-lite',
|
||||
'minimax-h3-max',
|
||||
'minimax-hailuo-2.3',
|
||||
'minimax-hailuo-2.3-fast',
|
||||
'minimax-hailuo-02',
|
||||
]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- H3 request shape ------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider.generate H3 request shape', () => {
|
||||
it('creates a v2 task with a ratio and polls it', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
const result = await generate({
|
||||
model: 'minimax-h3-lite',
|
||||
seconds: 6,
|
||||
size: '480p',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
});
|
||||
|
||||
expect(result).toBe('https://minimax.example/out.mp4');
|
||||
expect(sentUrl()).toBe('https://api.minimax.io/v2/video_generation');
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit & {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer test-key');
|
||||
expect(sentBody()).toEqual({
|
||||
model: 'MiniMax-H3-Lite',
|
||||
content: [{ type: 'text', text: 'a ball rolls' }],
|
||||
duration: 6,
|
||||
resolution: '480P',
|
||||
ratio: '9:16',
|
||||
});
|
||||
expect(sentUrl(1)).toBe(
|
||||
'https://api.minimax.io/v2/query/video_generation/task-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the model name as MiniMax spells it and 2K upper-cased', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'MiniMax:minimax/minimax-h3', size: '2K' });
|
||||
expect(sentBody()).toMatchObject({
|
||||
model: 'MiniMax-H3',
|
||||
resolution: '2K',
|
||||
duration: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults the ratio to 16:9 and ignores ratios MiniMax lacks', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'minimax-h3', width: 1000, height: 300 });
|
||||
expect(sentBody().ratio).toBe('16:9');
|
||||
});
|
||||
|
||||
it('sends frames with roles and no ratio', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3',
|
||||
input_reference: 'data:image/png;base64,AAAA',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
const body = sentBody();
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'a ball rolls' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,AAAA' },
|
||||
role: 'first_frame',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/last.png' },
|
||||
role: 'last_frame',
|
||||
},
|
||||
]);
|
||||
expect('ratio' in body).toBe(false);
|
||||
});
|
||||
|
||||
it('takes a last frame on its own', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
});
|
||||
expect(
|
||||
sentBody().content.map((c: { role?: string }) => c.role),
|
||||
).toEqual([undefined, 'last_frame']);
|
||||
});
|
||||
|
||||
it('wraps raw base64 as a PNG data URI', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'minimax-h3', input_reference: 'BBBB' });
|
||||
expect(sentBody().content[1].image_url.url).toBe(
|
||||
'data:image/png;base64,BBBB',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends references with a ratio', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3-max',
|
||||
reference_images: ['https://example.com/a.png', 'CCCC'],
|
||||
width: 4,
|
||||
height: 3,
|
||||
});
|
||||
const body = sentBody();
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'a ball rolls' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/a.png' },
|
||||
role: 'reference_image',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,CCCC' },
|
||||
role: 'reference_image',
|
||||
},
|
||||
]);
|
||||
expect(body.ratio).toBe('4:3');
|
||||
});
|
||||
|
||||
it("drops the chat API's /v1 from a base URL override", async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await withTestActor(() =>
|
||||
makeProvider({
|
||||
apiBaseUrl: 'https://api.minimaxi.com/v1/',
|
||||
}).generate({ prompt: 'hi' }),
|
||||
);
|
||||
expect(sentUrl()).toBe('https://api.minimaxi.com/v2/video_generation');
|
||||
});
|
||||
|
||||
it('keeps polling while the task is queued or running', async () => {
|
||||
mockH3Flow(
|
||||
h3Task({ status: 'queued', content: undefined, usage: {} }),
|
||||
h3Task({ status: 'running', content: undefined, usage: {} }),
|
||||
h3Task(),
|
||||
);
|
||||
const result = await generate({});
|
||||
expect(result).toBe('https://minimax.example/out.mp4');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('treats a rate-limited poll as a missed poll', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task_id: 'task-1' }));
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'rate_limit_error',
|
||||
message: 'rate limit (1002)',
|
||||
},
|
||||
},
|
||||
429,
|
||||
),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ task: h3Task() }));
|
||||
const result = await generate({});
|
||||
expect(result).toBe('https://minimax.example/out.mp4');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns the sample clip in test mode without calling MiniMax', async () => {
|
||||
const result = await generate({ test_mode: true });
|
||||
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Hailuo request shape --------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider.generate Hailuo request shape', () => {
|
||||
it('creates a v1 task, polls it and exchanges the file id for a URL', async () => {
|
||||
mockHailuoFlow(hailuoTask('Preparing'), hailuoTask('Success'));
|
||||
const result = await generate({
|
||||
model: 'minimax-hailuo-2.3',
|
||||
seconds: 10,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
|
||||
expect(result).toBe('https://minimax.example/hailuo.mp4');
|
||||
expect(sentUrl()).toBe('https://api.minimax.io/v1/video_generation');
|
||||
expect(sentBody()).toEqual({
|
||||
model: 'MiniMax-Hailuo-2.3',
|
||||
prompt: 'a ball rolls',
|
||||
duration: 10,
|
||||
resolution: '768P',
|
||||
});
|
||||
expect(sentUrl(1)).toBe(
|
||||
'https://api.minimax.io/v1/query/video_generation?task_id=task-1',
|
||||
);
|
||||
expect(sentUrl(3)).toBe(
|
||||
'https://api.minimax.io/v1/files/retrieve?file_id=file-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to 6 seconds at 1080p, which takes no 10-second clip', async () => {
|
||||
mockHailuoFlow(hailuoTask('Success'));
|
||||
await generate({
|
||||
model: 'minimax-hailuo-2.3-fast',
|
||||
seconds: 10,
|
||||
size: '1080p',
|
||||
});
|
||||
expect(sentBody()).toMatchObject({
|
||||
model: 'MiniMax-Hailuo-2.3-Fast',
|
||||
duration: 6,
|
||||
resolution: '1080P',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends frames as first_frame_image and last_frame_image', async () => {
|
||||
mockHailuoFlow(hailuoTask('Success'));
|
||||
await generate({
|
||||
model: 'minimax-hailuo-02',
|
||||
size: '512p',
|
||||
input_reference: 'DDDD',
|
||||
last_frame: 'https://example.com/last.png',
|
||||
});
|
||||
expect(sentBody()).toEqual({
|
||||
model: 'MiniMax-Hailuo-02',
|
||||
prompt: 'a ball rolls',
|
||||
duration: 6,
|
||||
resolution: '512P',
|
||||
first_frame_image: 'data:image/png;base64,DDDD',
|
||||
last_frame_image: 'https://example.com/last.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -- Validation ------------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider.generate rejects invalid input before calling MiniMax', () => {
|
||||
it.each<[string, Partial<IGenerateVideoParams>]>([
|
||||
['a blank prompt', { prompt: ' ' }],
|
||||
[
|
||||
'a prompt over 7000 characters on H3',
|
||||
{ model: 'minimax-h3', prompt: 'x'.repeat(7001) },
|
||||
],
|
||||
[
|
||||
'a prompt over 2000 characters on Hailuo',
|
||||
{ model: 'minimax-hailuo-2.3', prompt: 'x'.repeat(2001) },
|
||||
],
|
||||
[
|
||||
'a source video',
|
||||
{ model: 'minimax-h3', input_video: 'https://example.com/v.mp4' },
|
||||
],
|
||||
[
|
||||
'reference images on Hailuo',
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
reference_images: ['https://example.com/r.png'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'reference images with a first frame on H3',
|
||||
{
|
||||
model: 'minimax-h3',
|
||||
input_reference: 'https://example.com/f.png',
|
||||
reference_images: ['https://example.com/r.png'],
|
||||
},
|
||||
],
|
||||
[
|
||||
'more than 5 images on H3',
|
||||
{
|
||||
model: 'minimax-h3',
|
||||
reference_images: Array(6).fill('https://example.com/r.png'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'more than 2 images on H3 Max',
|
||||
{
|
||||
model: 'minimax-h3-max',
|
||||
reference_images: Array(3).fill('https://example.com/r.png'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'a last frame on Hailuo 2.3',
|
||||
{
|
||||
model: 'minimax-hailuo-2.3',
|
||||
input_reference: 'https://example.com/f.png',
|
||||
last_frame: 'https://example.com/l.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a last frame without a first frame on Hailuo 02',
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
last_frame: 'https://example.com/l.png',
|
||||
},
|
||||
],
|
||||
[
|
||||
'512p without a first frame on Hailuo 02',
|
||||
{ model: 'minimax-hailuo-02', size: '512p' },
|
||||
],
|
||||
[
|
||||
'a non-string image',
|
||||
{ model: 'minimax-h3', input_reference: 42 as never },
|
||||
],
|
||||
[
|
||||
'a non-array reference_images',
|
||||
{ model: 'minimax-h3', reference_images: 'x' as never },
|
||||
],
|
||||
])('rejects %s', async (_label, params) => {
|
||||
await expect(generate(params)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a 7000-character prompt on H3', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({ model: 'minimax-h3', prompt: 'x'.repeat(7000) });
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts five images on H3', async () => {
|
||||
mockH3Flow(h3Task());
|
||||
await generate({
|
||||
model: 'minimax-h3',
|
||||
reference_images: Array(5).fill('https://example.com/r.png'),
|
||||
});
|
||||
expect(sentBody().content).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Billing ---------------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider billing', () => {
|
||||
it.each<[Partial<IGenerateVideoParams>, string, string, number]>([
|
||||
[
|
||||
{ model: 'minimax-h3', seconds: 5 },
|
||||
'minimax-h3',
|
||||
'per-second:768p',
|
||||
5,
|
||||
],
|
||||
[
|
||||
{ model: 'minimax-h3', seconds: 8, size: '2k' },
|
||||
'minimax-h3',
|
||||
'per-second:2k',
|
||||
8,
|
||||
],
|
||||
[
|
||||
{ model: 'minimax-h3-lite', seconds: 4, size: '480p' },
|
||||
'minimax-h3-lite',
|
||||
'per-second:480p',
|
||||
4,
|
||||
],
|
||||
])(
|
||||
'meters %o per second under its cost key',
|
||||
async (params, id, key, secs) => {
|
||||
mockH3Flow(
|
||||
h3Task({ duration: secs, usage: { output_seconds: secs } }),
|
||||
);
|
||||
await generate(params);
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
|
||||
const [, usageType, amount, cost] =
|
||||
incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe(`minimax-video-generation:${id}:${key}`);
|
||||
expect(amount).toBe(secs);
|
||||
expect(cost).toBeCloseTo(secs * rate(id, key) * 1_000_000);
|
||||
},
|
||||
);
|
||||
|
||||
it('bills the seconds of output the task reports', async () => {
|
||||
mockH3Flow(h3Task({ duration: 5, usage: { output_seconds: 4 } }));
|
||||
await generate({ model: 'minimax-h3', seconds: 5 });
|
||||
const [, , amount, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(amount).toBe(4);
|
||||
expect(cost).toBeCloseTo(4 * 8 * 1_000_000);
|
||||
});
|
||||
|
||||
it('bills the requested seconds when the task reports no usage', async () => {
|
||||
mockH3Flow(h3Task({ duration: undefined, usage: undefined }));
|
||||
await generate({ model: 'minimax-h3', seconds: 7 });
|
||||
expect(incrementUsageSpy.mock.calls[0]![2]).toBe(7);
|
||||
});
|
||||
|
||||
it('shortens an H3 clip to what the balance buys', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(6 * 8 * 1_000_000);
|
||||
mockH3Flow(h3Task({ duration: 6, usage: { output_seconds: 6 } }));
|
||||
await generate({ model: 'minimax-h3', seconds: 10 });
|
||||
expect(sentBody().duration).toBe(6);
|
||||
});
|
||||
|
||||
it('rejects with insufficient_funds before creating an H3 task it cannot afford', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(1000);
|
||||
await expect(generate({ model: 'minimax-h3' })).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each<[Partial<IGenerateVideoParams>, string, string]>([
|
||||
[
|
||||
{ model: 'minimax-hailuo-2.3', seconds: 10 },
|
||||
'minimax-hailuo-2.3',
|
||||
'per-video:768p:10',
|
||||
],
|
||||
[
|
||||
{ model: 'minimax-hailuo-02', size: '1080p' },
|
||||
'minimax-hailuo-02',
|
||||
'per-video:1080p:6',
|
||||
],
|
||||
[
|
||||
{
|
||||
model: 'minimax-hailuo-02',
|
||||
size: '512p',
|
||||
seconds: 10,
|
||||
input_reference: 'https://example.com/f.png',
|
||||
},
|
||||
'minimax-hailuo-02',
|
||||
'per-video:512p:10',
|
||||
],
|
||||
])('meters a Hailuo clip %o as one video', async (params, id, key) => {
|
||||
mockHailuoFlow(hailuoTask('Success'));
|
||||
await generate(params);
|
||||
const [, usageType, amount, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe(`minimax-video-generation:${id}:${key}`);
|
||||
expect(amount).toBe(1);
|
||||
expect(cost).toBe(rate(id, key) * 1_000_000);
|
||||
});
|
||||
|
||||
it('rejects with insufficient_funds before creating a Hailuo task it cannot afford', async () => {
|
||||
remainingUsageSpy.mockResolvedValue(1000);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 402,
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Failures --------------------------------------------------------
|
||||
|
||||
describe('MiniMaxVideoProvider failures', () => {
|
||||
it.each(['failed', 'cancelled'])(
|
||||
'maps a %s H3 task to upstream_failed and meters nothing',
|
||||
async (status) => {
|
||||
mockH3Flow(
|
||||
h3Task({
|
||||
status,
|
||||
content: undefined,
|
||||
error: { code: '1000', message: 'unknown error' },
|
||||
}),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('maps an H3 task failed by the content filter to moderation_flagged', async () => {
|
||||
mockH3Flow(
|
||||
h3Task({
|
||||
status: 'failed',
|
||||
content: undefined,
|
||||
error: {
|
||||
code: '1026',
|
||||
message: 'video description contains sensitive content',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('carries a v2 parameter rejection with its status and code', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'bad_request_error',
|
||||
message:
|
||||
'invalid params, model MiniMax-H3-Lite does not support resolution 2K (2013)',
|
||||
http_code: '400',
|
||||
},
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
status: 400,
|
||||
code: '2013',
|
||||
provider: 'minimax',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a model MiniMax no longer serves as upstream_failed', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'bad_request_error',
|
||||
message:
|
||||
'invalid params, this model is not supported by /v2/video_generation (2013)',
|
||||
},
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the auth message behind a 401', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authorized_error',
|
||||
message:
|
||||
'login fail: carry the key in Authorization (1004)',
|
||||
},
|
||||
},
|
||||
401,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: 'MiniMax rejected the API key',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces an empty balance as status 402 with the MiniMax code', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'insufficient_balance_error',
|
||||
message: 'insufficient balance (1008)',
|
||||
},
|
||||
},
|
||||
402,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
status: 402,
|
||||
code: '1008',
|
||||
provider: 'minimax',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a content block at submit to moderation_flagged', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'unprocessable_entity_error',
|
||||
message: 'input new_sensitive (1026)',
|
||||
},
|
||||
},
|
||||
422,
|
||||
),
|
||||
);
|
||||
await expect(generate({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a v1 verdict out of base_resp under HTTP 200', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: '',
|
||||
base_resp: {
|
||||
status_code: 1008,
|
||||
status_msg: 'insufficient balance',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
status: 402,
|
||||
code: '1008',
|
||||
provider: 'minimax',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a Hailuo model MiniMax no longer serves as upstream_failed', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: '',
|
||||
base_resp: {
|
||||
status_code: 2013,
|
||||
status_msg: 'invalid params, incorrect model param input',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a failed Hailuo task to upstream_failed and meters nothing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(hailuoTask('Fail')));
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
legacyCode: 'upstream_failed',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a Hailuo clip removed by the output filter to moderation_flagged', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
task_id: 'task-1',
|
||||
status: 'Fail',
|
||||
base_resp: {
|
||||
status_code: 1027,
|
||||
status_msg: 'output new_sensitive',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
generate({ model: 'minimax-hailuo-2.3' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'moderation_flagged',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when the finished H3 task has no video', async () => {
|
||||
mockH3Flow(h3Task({ content: {} }));
|
||||
await expect(generate({})).rejects.toThrow(/video URL/);
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when the Hailuo file has no download URL', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ task_id: 'task-1', base_resp: ok }),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(hailuoTask('Success')));
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({ file: { file_id: 1 }, base_resp: ok }),
|
||||
);
|
||||
await expect(generate({ model: 'minimax-hailuo-2.3' })).rejects.toThrow(
|
||||
/video URL/,
|
||||
);
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,684 @@
|
||||
/*
|
||||
* 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 type { Actor } from '../../../../core/actor.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
|
||||
import type { CreditHold } from '../../../../services/metering/types.js';
|
||||
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
|
||||
import { readBoundedBody } from '../../../util/fileInput.js';
|
||||
import { toUrlOrDataUri } from '../../../util/imageInput.js';
|
||||
import { sanitizeUpstreamMessage } from '../../../util/upstreamErrors.js';
|
||||
import { capSecondsToRemainingCredits } from '../../creditCap.js';
|
||||
import type { IGenerateVideoParams, IVideoModel } from '../../types.js';
|
||||
import { VideoProvider } from '../VideoProvider.js';
|
||||
import {
|
||||
pollUntilSettled,
|
||||
VIDEO_HOLD_TTL_MS,
|
||||
videoJobFailure,
|
||||
} from '../polling.js';
|
||||
import {
|
||||
MINIMAX_VIDEO_GENERATION_MODELS,
|
||||
MINIMAX_VIDEO_SPECS,
|
||||
type MiniMaxVideoSpec,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_TEST_VIDEO_URL = 'https://assets.puter.site/txt2vid.mp4';
|
||||
const DEFAULT_BASE_URL = 'https://api.minimax.io';
|
||||
const DEFAULT_MODEL = 'minimax-h3';
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
const PROVIDER = 'minimax';
|
||||
|
||||
const ASPECT_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
|
||||
const DEFAULT_ASPECT_RATIO = '16:9';
|
||||
|
||||
// MiniMax's business codes, from the trailing "(NNNN)" of a v2 error message
|
||||
// or `base_resp.status_code` of a v1 reply.
|
||||
// https://platform.minimax.io/docs/api-reference/errorcode
|
||||
const AUTH_CODES = new Set([1004, 2049]);
|
||||
const BALANCE_CODES = new Set([1008, 2053, 2056, 2067]);
|
||||
const RATE_LIMIT_CODES = new Set([1002, 1039, 1041, 1045]);
|
||||
const MODERATION_CODES = new Set([1026, 1027]);
|
||||
const MODEL_UNAVAILABLE_CODES = new Set([2022, 2061, 2068, 2069]);
|
||||
const INVALID_PARAM_CODES = new Set([2013, 2014]);
|
||||
/** A 2013 about the model itself, which means the catalog has drifted. */
|
||||
const UNSUPPORTED_MODEL_PATTERN =
|
||||
/this model is not supported|incorrect model param/i;
|
||||
|
||||
type MiniMaxVideoConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
/** Test hook: polling cadence for task status checks. */
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
interface MiniMaxEnvelope {
|
||||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
error?: { type?: string; message?: string; http_code?: string };
|
||||
}
|
||||
|
||||
interface H3Task {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
content?: { url?: string };
|
||||
error?: { code?: string; message?: string };
|
||||
duration?: number;
|
||||
usage?: { output_seconds?: number };
|
||||
}
|
||||
|
||||
interface HailuoTask {
|
||||
task_id?: string;
|
||||
status?: 'Preparing' | 'Queueing' | 'Processing' | 'Success' | 'Fail';
|
||||
file_id?: string;
|
||||
}
|
||||
|
||||
const badRequest = (message: string) =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
const present = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null;
|
||||
|
||||
const statusForCode = (code: number, httpStatus: number): number => {
|
||||
if (AUTH_CODES.has(code)) return 401;
|
||||
if (BALANCE_CODES.has(code)) return 402;
|
||||
if (RATE_LIMIT_CODES.has(code)) return 429;
|
||||
if (INVALID_PARAM_CODES.has(code)) return 400;
|
||||
return httpStatus >= 400 ? httpStatus : 502;
|
||||
};
|
||||
|
||||
/** The code a v2 error message ends with, e.g. "invalid params (2013)". */
|
||||
const parseCode = (message: string): number | undefined => {
|
||||
const match = message.match(/\((\d{4,5})\)\s*$/);
|
||||
return match ? Number.parseInt(match[1], 10) : undefined;
|
||||
};
|
||||
|
||||
/** A request MiniMax refused, shaped for `translateProviderError`. */
|
||||
const minimaxError = (
|
||||
code: number | undefined,
|
||||
message: string,
|
||||
status: number,
|
||||
): Error => {
|
||||
// A model MiniMax doesn't serve is Puter's pick, so the catalog has
|
||||
// drifted from MiniMax's: our fault, not the caller's, and worth
|
||||
// someone's attention.
|
||||
if (
|
||||
code !== undefined &&
|
||||
(MODEL_UNAVAILABLE_CODES.has(code) ||
|
||||
(code === 2013 && UNSUPPORTED_MODEL_PATTERN.test(message)))
|
||||
) {
|
||||
return new HttpError(
|
||||
502,
|
||||
'MiniMax does not support the model Puter requested',
|
||||
{
|
||||
legacyCode: 'upstream_failed',
|
||||
alarmSeverity: 'warning',
|
||||
fields: {
|
||||
provider: PROVIDER,
|
||||
upstreamCode: String(code),
|
||||
upstreamMessage: sanitizeUpstreamMessage(message),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (code !== undefined && MODERATION_CODES.has(code)) {
|
||||
return new HttpError(
|
||||
400,
|
||||
sanitizeUpstreamMessage(message) ||
|
||||
'MiniMax rejected the request under its content policy',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
code: 'moderation_flagged',
|
||||
fields: { provider: PROVIDER, upstreamCode: String(code) },
|
||||
},
|
||||
);
|
||||
}
|
||||
// MiniMax's auth messages point at its own console; the caller can't act
|
||||
// on them.
|
||||
const isAuthFailure = code !== undefined && AUTH_CODES.has(code);
|
||||
return Object.assign(
|
||||
new Error(isAuthFailure ? 'MiniMax rejected the API key' : message),
|
||||
{
|
||||
status:
|
||||
code === undefined
|
||||
? status >= 400
|
||||
? status
|
||||
: 502
|
||||
: statusForCode(code, status),
|
||||
code: code === undefined ? undefined : String(code),
|
||||
provider: PROVIDER,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* MiniMax video generation (H3 and Hailuo).
|
||||
*
|
||||
* H3 models go through the v2 task API: a `content` list of text, frames and
|
||||
* references creates a task that is polled until it leaves queued/running, and
|
||||
* the clip's URL comes back on the task. Billing is per second of output at a
|
||||
* rate set by resolution. Hailuo models go through the v1 API, which takes a
|
||||
* prompt and frame images, is polled on its own status endpoint and hands back
|
||||
* a file id to exchange for a download URL. They are billed per clip, by
|
||||
* resolution and length.
|
||||
* https://platform.minimax.io/docs/api-reference/video-generation-v2-create
|
||||
*/
|
||||
export class MiniMaxVideoProvider extends VideoProvider {
|
||||
#apiKey: string;
|
||||
#baseUrl: string;
|
||||
#pollIntervalMs: number;
|
||||
#meteringService: AiMeteringService;
|
||||
|
||||
constructor(
|
||||
config: MiniMaxVideoConfig,
|
||||
meteringService: AiMeteringService,
|
||||
) {
|
||||
super();
|
||||
if (!config.apiKey) {
|
||||
throw new Error('MiniMax video generation requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
// The shared chat block's base URL ends in the chat API's `/v1`; the
|
||||
// video API lives beside it on the same host.
|
||||
this.#baseUrl = (config.apiBaseUrl ?? DEFAULT_BASE_URL)
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/v1$/, '');
|
||||
this.#pollIntervalMs =
|
||||
config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
models(): IVideoModel[] {
|
||||
return MINIMAX_VIDEO_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
async generate(params: IGenerateVideoParams): Promise<unknown> {
|
||||
const {
|
||||
prompt,
|
||||
model: requestedModel,
|
||||
seconds,
|
||||
duration,
|
||||
size,
|
||||
resolution,
|
||||
width,
|
||||
height,
|
||||
input_reference: inputReference,
|
||||
last_frame: lastFrame,
|
||||
reference_images: referenceImages,
|
||||
input_video: inputVideo,
|
||||
test_mode: testMode,
|
||||
} = params ?? {};
|
||||
|
||||
if (typeof prompt !== 'string' || !prompt.trim()) {
|
||||
throw badRequest('prompt must be a non-empty string');
|
||||
}
|
||||
const model = this.#getModel(requestedModel);
|
||||
const spec = MINIMAX_VIDEO_SPECS[model.id];
|
||||
if (prompt.length > spec.maxPromptChars) {
|
||||
throw badRequest(
|
||||
`${model.id} takes a prompt of at most ${spec.maxPromptChars} characters`,
|
||||
);
|
||||
}
|
||||
if (present(inputVideo)) {
|
||||
throw badRequest(`${model.id} does not accept a source video`);
|
||||
}
|
||||
|
||||
const resolutionKey = this.#resolution(size ?? resolution, model);
|
||||
const firstFrame = this.#image(inputReference, 'input_reference');
|
||||
const endFrame = this.#image(lastFrame, 'last_frame');
|
||||
const references = this.#references(referenceImages);
|
||||
this.#checkInputs(spec, model, resolutionKey, {
|
||||
firstFrame,
|
||||
endFrame,
|
||||
references,
|
||||
});
|
||||
|
||||
if (testMode) {
|
||||
return DEFAULT_TEST_VIDEO_URL;
|
||||
}
|
||||
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
const requestedSeconds =
|
||||
this.#coerceSeconds(seconds ?? duration) ??
|
||||
model.durationSeconds![0];
|
||||
const aspectRatio = this.#aspectRatio(width, height);
|
||||
const images = { firstFrame, endFrame, references };
|
||||
|
||||
if (spec.api === 'v2') {
|
||||
return this.#generateH3(actor, model, spec, {
|
||||
prompt,
|
||||
resolutionKey,
|
||||
requestedSeconds,
|
||||
aspectRatio,
|
||||
...images,
|
||||
});
|
||||
}
|
||||
return this.#generateHailuo(actor, model, spec, {
|
||||
prompt,
|
||||
resolutionKey,
|
||||
requestedSeconds,
|
||||
...images,
|
||||
});
|
||||
}
|
||||
|
||||
// -- H3 (v2 task API, per second) ---------------------------------------
|
||||
|
||||
async #generateH3(
|
||||
actor: Actor,
|
||||
model: IVideoModel,
|
||||
spec: MiniMaxVideoSpec,
|
||||
input: {
|
||||
prompt: string;
|
||||
resolutionKey: string;
|
||||
requestedSeconds: number;
|
||||
aspectRatio: string | undefined;
|
||||
firstFrame?: string;
|
||||
endFrame?: string;
|
||||
references: string[];
|
||||
},
|
||||
): Promise<string> {
|
||||
const costKey = `per-second:${input.resolutionKey}`;
|
||||
const centsPerSecond = model.costs?.[costKey];
|
||||
if (!centsPerSecond) {
|
||||
throw new Error(
|
||||
`No pricing configured for video model ${model.id} at ${costKey}`,
|
||||
);
|
||||
}
|
||||
const usageType = `minimax-video-generation:${model.id}:${costKey}`;
|
||||
const { seconds: cappedSeconds, hold } =
|
||||
await capSecondsToRemainingCredits({
|
||||
metering: this.#meteringService,
|
||||
actor,
|
||||
usageType,
|
||||
perSecondMicroCents: centsPerSecond * 1_000_000,
|
||||
requestedSeconds: input.requestedSeconds,
|
||||
allowedSeconds: model.durationSeconds,
|
||||
});
|
||||
|
||||
try {
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: 'text', text: input.prompt },
|
||||
];
|
||||
if (input.firstFrame) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: input.firstFrame },
|
||||
role: 'first_frame',
|
||||
});
|
||||
}
|
||||
if (input.endFrame) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: input.endFrame },
|
||||
role: 'last_frame',
|
||||
});
|
||||
}
|
||||
for (const url of input.references) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: 'reference_image',
|
||||
});
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
model: spec.apiModel,
|
||||
content,
|
||||
duration: cappedSeconds,
|
||||
resolution: input.resolutionKey.toUpperCase(),
|
||||
};
|
||||
// A clip from frames takes their shape; MiniMax requires a ratio
|
||||
// for everything else.
|
||||
if (!input.firstFrame && !input.endFrame) {
|
||||
body.ratio = input.aspectRatio ?? DEFAULT_ASPECT_RATIO;
|
||||
}
|
||||
|
||||
const created = await this.#request<{ task_id?: string }>(
|
||||
'POST',
|
||||
'/v2/video_generation',
|
||||
body,
|
||||
);
|
||||
if (!created.task_id) {
|
||||
throw new Error('MiniMax response did not include a task id');
|
||||
}
|
||||
const task = await pollUntilSettled<H3Task>({
|
||||
provider: PROVIDER,
|
||||
providerLabel: 'MiniMax',
|
||||
intervalMs: this.#pollIntervalMs,
|
||||
fetch: async () => {
|
||||
const { task } = await this.#request<{ task?: H3Task }>(
|
||||
'GET',
|
||||
`/v2/query/video_generation/${encodeURIComponent(created.task_id!)}`,
|
||||
);
|
||||
if (!task) {
|
||||
throw new Error('MiniMax task query returned no task');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
isPending: (task) =>
|
||||
task.status === 'queued' || task.status === 'running',
|
||||
});
|
||||
if (task.status !== 'succeeded') {
|
||||
throw videoJobFailure(
|
||||
PROVIDER,
|
||||
task.error?.message || `Video generation ${task.status}`,
|
||||
task.error?.code,
|
||||
);
|
||||
}
|
||||
const url = task.content?.url;
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
throw new Error('MiniMax response did not include a video URL');
|
||||
}
|
||||
|
||||
// MiniMax bills the seconds of output the task reports.
|
||||
const outputSeconds = task.usage?.output_seconds ?? task.duration;
|
||||
const billedSeconds =
|
||||
typeof outputSeconds === 'number' &&
|
||||
Number.isFinite(outputSeconds) &&
|
||||
outputSeconds > 0
|
||||
? Math.max(1, Math.round(outputSeconds))
|
||||
: cappedSeconds;
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
billedSeconds,
|
||||
Math.round(billedSeconds * centsPerSecond * 1_000_000),
|
||||
);
|
||||
return url;
|
||||
} finally {
|
||||
await hold.release();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Hailuo (v1 API, per clip) ------------------------------------------
|
||||
|
||||
async #generateHailuo(
|
||||
actor: Actor,
|
||||
model: IVideoModel,
|
||||
spec: MiniMaxVideoSpec,
|
||||
input: {
|
||||
prompt: string;
|
||||
resolutionKey: string;
|
||||
requestedSeconds: number;
|
||||
firstFrame?: string;
|
||||
endFrame?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
// A clip length the resolution doesn't offer falls back to the first
|
||||
// one it does, the way the driver treats any unsupported duration.
|
||||
const offered = (secs: number) =>
|
||||
model.costs?.[`per-video:${input.resolutionKey}:${secs}`];
|
||||
const clipSeconds = offered(input.requestedSeconds)
|
||||
? input.requestedSeconds
|
||||
: model.durationSeconds!.find(offered)!;
|
||||
const costKey = `per-video:${input.resolutionKey}:${clipSeconds}`;
|
||||
const centsPerVideo = model.costs![costKey]!;
|
||||
const usageType = `minimax-video-generation:${model.id}:${costKey}`;
|
||||
const microCents = centsPerVideo * 1_000_000;
|
||||
const hold: CreditHold | null =
|
||||
await this.#meteringService.reserveAiCredits(
|
||||
actor,
|
||||
usageType,
|
||||
microCents,
|
||||
{ ttlMs: VIDEO_HOLD_TTL_MS },
|
||||
);
|
||||
if (!hold) {
|
||||
throw insufficientCreditsError();
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: spec.apiModel,
|
||||
prompt: input.prompt,
|
||||
duration: clipSeconds,
|
||||
resolution: input.resolutionKey.toUpperCase(),
|
||||
};
|
||||
if (input.firstFrame) body.first_frame_image = input.firstFrame;
|
||||
if (input.endFrame) body.last_frame_image = input.endFrame;
|
||||
|
||||
const created = await this.#request<{ task_id?: string }>(
|
||||
'POST',
|
||||
'/v1/video_generation',
|
||||
body,
|
||||
);
|
||||
if (!created.task_id) {
|
||||
throw new Error('MiniMax response did not include a task id');
|
||||
}
|
||||
const task = await pollUntilSettled<HailuoTask>({
|
||||
provider: PROVIDER,
|
||||
providerLabel: 'MiniMax',
|
||||
intervalMs: this.#pollIntervalMs,
|
||||
fetch: () =>
|
||||
this.#request<HailuoTask>(
|
||||
'GET',
|
||||
`/v1/query/video_generation?task_id=${encodeURIComponent(created.task_id!)}`,
|
||||
),
|
||||
isPending: (task) =>
|
||||
task.status === 'Preparing' ||
|
||||
task.status === 'Queueing' ||
|
||||
task.status === 'Processing',
|
||||
});
|
||||
if (task.status !== 'Success' || !task.file_id) {
|
||||
throw videoJobFailure(
|
||||
PROVIDER,
|
||||
'MiniMax video generation failed',
|
||||
);
|
||||
}
|
||||
// The clip is a file in MiniMax's store; its download link is
|
||||
// minted on request and works for an hour.
|
||||
const { file } = await this.#request<{
|
||||
file?: { download_url?: string };
|
||||
}>(
|
||||
'GET',
|
||||
`/v1/files/retrieve?file_id=${encodeURIComponent(task.file_id)}`,
|
||||
);
|
||||
const url = file?.download_url;
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
throw new Error('MiniMax response did not include a video URL');
|
||||
}
|
||||
|
||||
await this.#meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
1,
|
||||
microCents,
|
||||
);
|
||||
return url;
|
||||
} finally {
|
||||
await hold.release();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Validation ---------------------------------------------------------
|
||||
|
||||
#checkInputs(
|
||||
spec: MiniMaxVideoSpec,
|
||||
model: IVideoModel,
|
||||
resolutionKey: string,
|
||||
inputs: {
|
||||
firstFrame?: string;
|
||||
endFrame?: string;
|
||||
references: string[];
|
||||
},
|
||||
) {
|
||||
const { firstFrame, endFrame, references } = inputs;
|
||||
if (endFrame && !spec.lastFrame) {
|
||||
throw badRequest(`${model.id} does not support last_frame`);
|
||||
}
|
||||
if (endFrame && !firstFrame && spec.api === 'v1') {
|
||||
throw badRequest(
|
||||
'last_frame requires a first-frame image via input_reference',
|
||||
);
|
||||
}
|
||||
if (references.length > 0) {
|
||||
if (!spec.references) {
|
||||
throw badRequest(
|
||||
`${model.id} does not support reference_images`,
|
||||
);
|
||||
}
|
||||
if (firstFrame || endFrame) {
|
||||
throw badRequest(
|
||||
'reference_images cannot be combined with input_reference or last_frame',
|
||||
);
|
||||
}
|
||||
}
|
||||
const imageCount =
|
||||
(firstFrame ? 1 : 0) + (endFrame ? 1 : 0) + references.length;
|
||||
if (imageCount > spec.maxImages) {
|
||||
throw badRequest(
|
||||
`${model.id} takes at most ${spec.maxImages} image(s), frames and references together`,
|
||||
);
|
||||
}
|
||||
if (spec.imageOnlyResolutions?.includes(resolutionKey) && !firstFrame) {
|
||||
throw badRequest(
|
||||
`${model.id} renders ${resolutionKey} only from an input_reference image`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #request<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${this.#baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const raw = await readBoundedBody(response, MAX_RESPONSE_BYTES, () =>
|
||||
minimaxError(undefined, 'MiniMax response too large', 502),
|
||||
);
|
||||
let payload: MiniMaxEnvelope = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw.toString('utf8'));
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
payload = parsed as MiniMaxEnvelope;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON; the status alone decides.
|
||||
}
|
||||
// A v1 reply carries its verdict in `base_resp` under HTTP 200; a v2
|
||||
// reply uses the HTTP status and an OpenAI-style `error`.
|
||||
const v1Code = payload.base_resp?.status_code;
|
||||
if (typeof v1Code === 'number' && v1Code !== 0) {
|
||||
throw minimaxError(
|
||||
v1Code,
|
||||
payload.base_resp?.status_msg ||
|
||||
`MiniMax request failed (code ${v1Code})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload.error?.message ||
|
||||
`MiniMax request failed (status ${response.status})`;
|
||||
throw minimaxError(parseCode(message), message, response.status);
|
||||
}
|
||||
return payload as unknown as T;
|
||||
}
|
||||
|
||||
#getModel(requestedModel?: string): IVideoModel {
|
||||
const wanted = (requestedModel ?? '').trim().toLowerCase();
|
||||
return (
|
||||
MINIMAX_VIDEO_GENERATION_MODELS.find(
|
||||
(m) =>
|
||||
m.id === wanted ||
|
||||
m.puterId === wanted ||
|
||||
m.aliases?.includes(wanted),
|
||||
) ??
|
||||
MINIMAX_VIDEO_GENERATION_MODELS.find((m) => m.id === DEFAULT_MODEL)!
|
||||
);
|
||||
}
|
||||
|
||||
#resolution(candidate: unknown, model: IVideoModel): string {
|
||||
const tiers = model.dimensions!;
|
||||
if (typeof candidate === 'string') {
|
||||
const wanted = candidate.trim().toLowerCase();
|
||||
if (tiers.includes(wanted)) return wanted;
|
||||
}
|
||||
return tiers[0];
|
||||
}
|
||||
|
||||
/** An image as MiniMax takes it: a URL or a data URI. */
|
||||
#image(value: unknown, label: string): string | undefined {
|
||||
if (!present(value)) return undefined;
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw badRequest(
|
||||
`${label} must be an image URL, data URI or base64`,
|
||||
);
|
||||
}
|
||||
return toUrlOrDataUri(value);
|
||||
}
|
||||
|
||||
#references(value: unknown): string[] {
|
||||
if (!present(value)) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
throw badRequest('reference_images must be an array');
|
||||
}
|
||||
return value.map((image) => this.#image(image, 'reference_images')!);
|
||||
}
|
||||
|
||||
#coerceSeconds(value: unknown): number | undefined {
|
||||
const numeric =
|
||||
typeof value === 'string' ? Number.parseInt(value, 10) : value;
|
||||
return typeof numeric === 'number' &&
|
||||
Number.isFinite(numeric) &&
|
||||
numeric > 0
|
||||
? Math.round(numeric)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Snap width/height to one of MiniMax's aspect ratios. */
|
||||
#aspectRatio(width?: number, height?: number): string | undefined {
|
||||
if (
|
||||
typeof width !== 'number' ||
|
||||
typeof height !== 'number' ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const gcd = (a: number, b: number): number =>
|
||||
b === 0 ? a : gcd(b, a % b);
|
||||
const w = Math.round(width);
|
||||
const h = Math.round(height);
|
||||
const d = gcd(w, h) || 1;
|
||||
const candidate = `${w / d}:${h / d}`;
|
||||
// Other ratios are left to the 16:9 default.
|
||||
return ASPECT_RATIOS.includes(candidate) ? candidate : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* 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 type { IVideoModel } from '../../types.js';
|
||||
|
||||
/** MiniMax-specific model behavior the shared IVideoModel shape can't carry. */
|
||||
export interface MiniMaxVideoSpec {
|
||||
/** The model name as MiniMax spells it on the wire. */
|
||||
apiModel: string;
|
||||
/**
|
||||
* `v2`: the H3 task API, billed per second of output. `v1`: the Hailuo API,
|
||||
* billed per clip, whose result is fetched through a file id.
|
||||
*/
|
||||
api: 'v1' | 'v2';
|
||||
/**
|
||||
* Frames and reference images together. MiniMax charges per image above
|
||||
* this quota, so more are refused rather than billed.
|
||||
*/
|
||||
maxImages: number;
|
||||
/** Whether `reference_images` are taken (H3 reference-to-video). */
|
||||
references: boolean;
|
||||
lastFrame: boolean;
|
||||
/** Resolutions MiniMax renders only from a first frame. */
|
||||
imageOnlyResolutions?: string[];
|
||||
maxPromptChars: number;
|
||||
}
|
||||
|
||||
// Duration ladder for the driver's normalization: element 0 is the fallback
|
||||
// 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,
|
||||
),
|
||||
];
|
||||
|
||||
// Prices from https://platform.minimax.io/docs/guides/pricing-video. H3 models
|
||||
// are per second of output, keyed `per-second:<resolution>`; Hailuo models are
|
||||
// per clip, keyed `per-video:<resolution>:<seconds>`, and a key's absence means
|
||||
// the model doesn't offer that combination. `default-duration-per-video` is
|
||||
// the default clip at the default resolution, for cross-provider sorting and
|
||||
// display only.
|
||||
export const MINIMAX_VIDEO_GENERATION_MODELS: IVideoModel[] = [
|
||||
{
|
||||
id: 'minimax-h3',
|
||||
puterId: 'minimax:minimax/minimax-h3',
|
||||
name: 'MiniMax H3',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 8,
|
||||
'per-second:2k': 13,
|
||||
'default-duration-per-video': 40,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['768p', '2k'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-h3-highspeed',
|
||||
puterId: 'minimax:minimax/minimax-h3-highspeed',
|
||||
name: 'MiniMax H3 Highspeed',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 6,
|
||||
'default-duration-per-video': 30,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['768p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-h3-lite',
|
||||
puterId: 'minimax:minimax/minimax-h3-lite',
|
||||
name: 'MiniMax H3 Lite',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 4,
|
||||
'per-second:480p': 2.5,
|
||||
'default-duration-per-video': 20,
|
||||
},
|
||||
durationSeconds: seconds(5, 4, 15),
|
||||
dimensions: ['768p', '480p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-h3-max',
|
||||
puterId: 'minimax:minimax/minimax-h3-max',
|
||||
name: 'MiniMax H3 Max',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:768p': 8,
|
||||
'per-second:480p': 5,
|
||||
'default-duration-per-video': 40,
|
||||
},
|
||||
durationSeconds: seconds(5, 5, 15),
|
||||
dimensions: ['768p', '480p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-hailuo-2.3',
|
||||
puterId: 'minimax:minimax/minimax-hailuo-2.3',
|
||||
name: 'MiniMax Hailuo 2.3',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-video:768p:6': 28,
|
||||
'per-video:768p:10': 56,
|
||||
'per-video:1080p:6': 49,
|
||||
'default-duration-per-video': 28,
|
||||
},
|
||||
durationSeconds: [6, 10],
|
||||
dimensions: ['768p', '1080p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-hailuo-2.3-fast',
|
||||
puterId: 'minimax:minimax/minimax-hailuo-2.3-fast',
|
||||
name: 'MiniMax Hailuo 2.3 Fast',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-video:768p:6': 19,
|
||||
'per-video:768p:10': 32,
|
||||
'per-video:1080p:6': 33,
|
||||
'default-duration-per-video': 19,
|
||||
},
|
||||
durationSeconds: [6, 10],
|
||||
dimensions: ['768p', '1080p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'minimax-hailuo-02',
|
||||
puterId: 'minimax:minimax/minimax-hailuo-02',
|
||||
name: 'MiniMax Hailuo 02',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-video:768p:6': 28,
|
||||
'per-video:768p:10': 56,
|
||||
'per-video:1080p:6': 49,
|
||||
'per-video:512p:6': 10,
|
||||
'per-video:512p:10': 15,
|
||||
'default-duration-per-video': 28,
|
||||
},
|
||||
durationSeconds: [6, 10],
|
||||
dimensions: ['768p', '1080p', '512p'],
|
||||
fps: [24],
|
||||
},
|
||||
];
|
||||
|
||||
// Capabilities per https://platform.minimax.io/docs/api-reference/video-generation-v2-create
|
||||
// (H3) and the text-, image- and frames-to-video pages under
|
||||
// https://platform.minimax.io/docs/api-reference/video-generation-t2v (Hailuo).
|
||||
export const MINIMAX_VIDEO_SPECS: Record<string, MiniMaxVideoSpec> = {
|
||||
'minimax-h3': {
|
||||
apiModel: 'MiniMax-H3',
|
||||
api: 'v2',
|
||||
maxImages: 5,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-h3-highspeed': {
|
||||
apiModel: 'MiniMax-H3-Highspeed',
|
||||
api: 'v2',
|
||||
maxImages: 5,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-h3-lite': {
|
||||
apiModel: 'MiniMax-H3-Lite',
|
||||
api: 'v2',
|
||||
maxImages: 5,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-h3-max': {
|
||||
apiModel: 'MiniMax-H3-Max',
|
||||
api: 'v2',
|
||||
maxImages: 2,
|
||||
references: true,
|
||||
lastFrame: true,
|
||||
maxPromptChars: 7000,
|
||||
},
|
||||
'minimax-hailuo-2.3': {
|
||||
apiModel: 'MiniMax-Hailuo-2.3',
|
||||
api: 'v1',
|
||||
maxImages: 1,
|
||||
references: false,
|
||||
lastFrame: false,
|
||||
maxPromptChars: 2000,
|
||||
},
|
||||
'minimax-hailuo-2.3-fast': {
|
||||
apiModel: 'MiniMax-Hailuo-2.3-Fast',
|
||||
api: 'v1',
|
||||
maxImages: 1,
|
||||
references: false,
|
||||
lastFrame: false,
|
||||
maxPromptChars: 2000,
|
||||
},
|
||||
'minimax-hailuo-02': {
|
||||
apiModel: 'MiniMax-Hailuo-02',
|
||||
api: 'v1',
|
||||
maxImages: 2,
|
||||
references: false,
|
||||
lastFrame: true,
|
||||
imageOnlyResolutions: ['512p'],
|
||||
maxPromptChars: 2000,
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -202,7 +202,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 video clips from text or a reference image with Wan, Seedance, Veo, Kling and other models, or edit a video you supply with Gemini Omni, FLUX 3 or Kling Omni
|
||||
- **[`puter.ai.txt2vid()`](/AI/txt2vid/)** - Generate short video clips from text or a reference image with Wan, Seedance, Veo, Kling, MiniMax H3 and other models, or edit a video you supply with Gemini Omni, FLUX 3 or Kling Omni
|
||||
- **[`puter.ai.speech2txt()`](/AI/speech2txt/)** - Transcribe audio recordings into text
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: puter.ai.txt2vid()
|
||||
description: Generate short video clips from text or reference images, or edit a video you supply, with Wan, Seedance, Veo, Kling, Gemini Omni and other models through Puter.js.
|
||||
description: Generate short video clips from text or reference images, or edit a video you supply, with Wan, Seedance, Veo, Kling, MiniMax H3, Gemini Omni and other models through Puter.js.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
Given a prompt, generate a short video clip using AI. Puter routes the request to one of four upstream providers (Together AI, BytePlus Seedance, Google Gemini, Kling) 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. Gemini Omni, FLUX 3 and the Kling Omni models can also edit a video you supply.
|
||||
Given a prompt, generate a short video clip using AI. Puter routes the request to one of five upstream providers (Together AI, BytePlus Seedance, Google Gemini, Kling, MiniMax) 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. Gemini Omni, FLUX 3 and the Kling Omni models can also edit a video you supply.
|
||||
|
||||
## Syntax
|
||||
|
||||
@@ -34,12 +34,12 @@ Additional settings for the generation request.
|
||||
|--------|------|-------------|
|
||||
| `prompt` | `String` | Text description for the video generation |
|
||||
| `model` | `String` | Video model to use. Defaults to `'togetherai:wan-ai/wan2.7-t2v'` (Wan 2.7 text-to-video). See [Choosing a model](#choosing-a-model) |
|
||||
| `provider` | `String` | Pin the request to one provider: `'together-video-generation'`, `'byteplus-video-generation'`, `'gemini-video-generation'` or `'kling-video-generation'`. Only needed when a model id exists on more than one provider |
|
||||
| `provider` | `String` | Pin the request to one provider: `'together-video-generation'`, `'byteplus-video-generation'`, `'gemini-video-generation'`, `'kling-video-generation'` or `'minimax-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. Gemini Omni has no length control and rejects this option. `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, except on Gemini Omni, which rejects it. `resolution` is an alias |
|
||||
| `width`, `height` | `Number` | Output size in pixels on Together AI models sized in pixels; the aspect ratio on Seedance, Kling and Wan 2.7. Filled in from `size` when you leave them out |
|
||||
| `width`, `height` | `Number` | Output size in pixels on Together AI models sized in pixels; the aspect ratio on Seedance, Kling, MiniMax H3 and Wan 2.7. Filled in from `size` when you leave them out |
|
||||
| `input_reference` | `String \| Blob` | Image the clip starts from (image-to-video), on every provider. See [Image inputs](#image-inputs) for the formats it takes |
|
||||
| `last_frame` | `String \| Blob` | Image the clip ends on, same formats. Requires `input_reference` on Seedance |
|
||||
| `last_frame` | `String \| Blob` | Image the clip ends on, same formats. Requires `input_reference` on Seedance, Kling and Hailuo 02; the MiniMax H3 models also take it alone |
|
||||
| `reference_images` | `Array<String \| Blob>` | 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`, except on Kling |
|
||||
| `inputVideo` | `String \| Blob` | Video to edit, with the prompt describing the change. Only Gemini Omni, FLUX 3, Kling 3.0 Omni and Kling O1 accept it. See [Video input](#video-input) |
|
||||
| `negative_prompt` | `String` | Text describing what to avoid in the video (Together AI) |
|
||||
@@ -79,6 +79,7 @@ What each provider does with image inputs:
|
||||
| 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 Gemini | Yes, one PNG, JPEG or WebP image up to 5 MiB | No | No |
|
||||
| Kling | Yes, JPEG or PNG | Yes, with `input_reference`, on the models listed in [Kling options](#kling-options) | Kling 3.0 Omni and O1: up to 7, or 4 with `inputVideo` |
|
||||
| MiniMax | Yes, JPEG, PNG or WebP | Yes, on the H3 models, and with `input_reference` on Hailuo 02 | H3 models: up to 5 images in total with the frames, 2 on H3 Max; cannot be combined with frames |
|
||||
|
||||
#### Video input
|
||||
|
||||
@@ -274,6 +275,47 @@ An option the model lacks, or a combination it doesn't support, is rejected with
|
||||
|
||||
For more details, see the [Kling video API reference](https://kling.ai/document-api/api/video/3-0-omni/text-to-video).
|
||||
|
||||
#### MiniMax options
|
||||
|
||||
Available when using a MiniMax model (provider `'minimax-video-generation'`). The ids are MiniMax's own model names in lower case, and every model also answers to `minimax/<model>`. The H3 models take any whole number of seconds in their range and are priced per second. The Hailuo models render a 6 or 10 second clip for a flat price. All of them work in resolution tiers: pass `size` as a tier, or as `'WIDTHxHEIGHT'`, which selects the tier of the shorter side and the aspect ratio. Pass `'2k'` by name. They render at 24 frames per second.
|
||||
|
||||
| Model | Duration (s) | Resolutions | Audio | Last frame | Reference images |
|
||||
|-------|--------------|-------------|-------|------------|------------------|
|
||||
| `minimax-h3` (default) | 4 to 15, default 5 | `768p`, `2k` | Always | Yes | Up to 5 images in total |
|
||||
| `minimax-h3-highspeed` | 4 to 15, default 5 | `768p` | Always | Yes | Up to 5 images in total |
|
||||
| `minimax-h3-lite` | 4 to 15, default 5 | `768p`, `480p` | Always | Yes | Up to 5 images in total |
|
||||
| `minimax-h3-max` | 5 to 15, default 5 | `768p`, `480p` | Always | Yes | Up to 2 images in total |
|
||||
| `minimax-hailuo-2.3` | 6 or 10 | `768p`, `1080p` (6 seconds only) | No | No | No |
|
||||
| `minimax-hailuo-2.3-fast` | 6 or 10 | `768p`, `1080p` (6 seconds only) | No | No | No |
|
||||
| `minimax-hailuo-02` | 6 or 10 | `768p`, `1080p` (6 seconds only), `512p` (with `input_reference` only) | No | Yes | No |
|
||||
|
||||
The first resolution listed is the default. "Images in total" counts `input_reference`, `last_frame` and `reference_images` together.
|
||||
|
||||
| Model | Price |
|
||||
|-------|-------|
|
||||
| `minimax-h3` | $0.08 per second at `768p`, $0.13 at `2k` |
|
||||
| `minimax-h3-highspeed` | $0.06 per second |
|
||||
| `minimax-h3-lite` | $0.04 per second at `768p`, $0.025 at `480p` |
|
||||
| `minimax-h3-max` | $0.08 per second at `768p`, $0.05 at `480p` |
|
||||
| `minimax-hailuo-2.3` | $0.28 for 6 seconds at `768p`, $0.56 for 10 seconds at `768p`, $0.49 for 6 seconds at `1080p` |
|
||||
| `minimax-hailuo-2.3-fast` | $0.19 for 6 seconds at `768p`, $0.32 for 10 seconds at `768p`, $0.33 for 6 seconds at `1080p` |
|
||||
| `minimax-hailuo-02` | As Hailuo 2.3, plus $0.10 for 6 seconds and $0.15 for 10 seconds at `512p` |
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `model` | `String` | Video model to use, from the table above |
|
||||
| `prompt` | `String` | Up to 7,000 characters on the H3 models, 2,000 on the Hailuo models |
|
||||
| `seconds` | `Number` | Clip length, from the table above. Any other value falls back to the default. On the Hailuo models, 10 seconds at `1080p` falls back to 6 |
|
||||
| `size` | `String` | Resolution tier from the table above (case-insensitive), or `'WIDTHxHEIGHT'`. `resolution` is an alias |
|
||||
| `width`, `height` | `Number` | Optional. Only their ratio is used, to pick the aspect ratio of an H3 clip made from text or `reference_images`: `21:9`, `16:9`, `4:3`, `1:1`, `3:4` or `9:16`. Any other ratio gives `16:9`. A clip from `input_reference` or `last_frame` takes the image's shape. The Hailuo models ignore them |
|
||||
| `input_reference` | `String \| Blob` | Image used as the first frame, in any [image input](#image-inputs) format. JPEG, PNG or WebP, at least 256 pixels per side on the H3 models and more than 300 pixels on the short side on the Hailuo models |
|
||||
| `last_frame` | `String \| Blob` | Image used as the last frame, same formats. Requires `input_reference` on Hailuo 02. The H3 models also take it alone |
|
||||
| `reference_images` | `Array<String \| Blob>` | Images the model uses as subject, style or scene references, same formats, on the H3 models. Cannot be combined with `input_reference` or `last_frame` |
|
||||
|
||||
An option the model lacks, or a combination it doesn't support, is rejected with `bad_request` before any credits are spent. MiniMax returns a hosted URL that is temporary, so pass `puter_output_path` to keep a clip. A Hailuo link works for one hour.
|
||||
|
||||
For more details, see the [MiniMax video API reference](https://platform.minimax.io/docs/api-reference/video-generation-v2-create).
|
||||
|
||||
Any properties not set fall back to provider defaults.
|
||||
|
||||
#### Saving to Puter filesystem
|
||||
@@ -297,6 +339,7 @@ Every successful generation is charged to the user's AI credits according to the
|
||||
- **Seedance** is 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.
|
||||
- **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), including the default Wan 2.7, are shortened like Seedance, and charged at the model's highest published rate per second.
|
||||
- **Kling** is priced per second, and clips are shortened like Seedance. An edit of an `inputVideo` is as long as its source, so Puter holds the price of the longest source the model accepts (16 seconds on Kling 3.0 Omni, 10 on Kling O1) and charges for the length of the clip Kling returns.
|
||||
- **MiniMax** H3 models are priced per second, and clips are shortened like Seedance. The charge is for the seconds of output MiniMax reports. The Hailuo models are priced per clip, so those requests are either accepted at the clip's price or rejected with `insufficient_funds`.
|
||||
- **Gemini Omni** is priced per token and has no length control, so it's never shortened. Before generating, Puter counts the input tokens and holds an estimate of about $3.63 plus the input. The estimate covers 173,760 video tokens and 65,536 text and thinking tokens. Puter doesn't count the tokens of an `inputVideo`, so a request with one holds another 16,000 input tokens, about $0.02, which covers a 10-second video. Puter reads the video only after the estimate is held. A balance that can't cover it fails with `insufficient_funds` before generation starts. The charge is for the tokens Google reports for the clip. It can be higher than the estimate, because Google can go past the requested output limit. When Google's reply reports token usage but fails or has no usable clip, those tokens are charged and the request still fails.
|
||||
|
||||
Apart from that Gemini Omni case, 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/).
|
||||
@@ -309,7 +352,7 @@ Video generation is slow: expect anywhere from tens of seconds to several minute
|
||||
|
||||
A `Promise` that resolves to an `HTMLVideoElement` (in browsers) that you can append to the DOM straight away:
|
||||
|
||||
- `src` is the clip's URL: an `https:` URL on the provider's storage (Together AI or Seedance), or a `data:video/mp4;base64,…` URI holding the whole clip (Gemini Omni). Provider URLs are temporary; keep a copy with `puter_output_path` if you need the clip later.
|
||||
- `src` is the clip's URL: an `https:` URL on the provider's storage (Together AI, Seedance, Kling or MiniMax), or a `data:video/mp4;base64,…` URI holding the whole clip (Gemini Omni). 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()`.
|
||||
@@ -329,7 +372,7 @@ A rejection carries the error body exactly as the backend sent it, or `{ message
|
||||
| `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: `together`, `byteplus` or `gemini`. Present on errors raised while a job was running. |
|
||||
| `provider` | Which upstream handled the request: `together`, `byteplus`, `gemini`, `kling` or `minimax`. 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. |
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ The reservation is a conservative estimate based on verified generation usage, n
|
||||
|
||||
Kling video models take a prompt of up to 3,072 characters (Kling 3.0 models) or 2,500 (the others, and Kling 3.0 Turbo with `input_reference`). Kling 3.0 Omni and Kling O1 take a source video (`inputVideo`) as a URL or as a Puter file of up to 200 MiB, which fails with `413 input_too_large` above that. Kling fetches either one itself. A Puter file is passed as a link that works for 19 minutes. The source must be an MP4 or QuickTime video of 3 to 15.5 seconds (10 on Kling O1). Kling rejects a source outside its limits, or one it can't decode, with `upstream_bad_request`, and the request isn't charged. An edit holds the price of a 16-second clip (10 on Kling O1). Each call to Kling times out after 60 seconds, and a job is polled every 5 seconds.
|
||||
|
||||
MiniMax video models take a prompt of up to 7,000 characters (H3 models) or 2,000 (Hailuo models), and up to 5 images as frames and references together (2 on H3 Max and Hailuo 02, 1 on Hailuo 2.3 and 2.3 Fast). MiniMax fetches an image URL itself, and a Puter file is sent inline. Each call to MiniMax times out after 60 seconds, and a job is polled every 5 seconds.
|
||||
|
||||
FLUX 3 on Together AI takes a source video (`inputVideo`) of up to 32 MiB as a Puter file, or up to 1 MiB as a `data:` URI. A larger one fails with `413 input_too_large` before the job starts. A Puter file is passed to Together AI as a link that works for 19 minutes, and a URL is passed as is. Together AI fetches either one.
|
||||
|
||||
### Puter file media inputs
|
||||
|
||||
@@ -340,8 +340,9 @@
|
||||
* @typedef {Object} Txt2VidOptions
|
||||
* @property {string} [prompt] Text description of the clip.
|
||||
* @property {string} [provider] Pin the request to one provider: `'together-video-generation'`,
|
||||
* `'byteplus-video-generation'`, `'gemini-video-generation'` or `'kling-video-generation'`. Defaults to the provider that owns
|
||||
* `model`, or Together AI when neither is given.
|
||||
* `'byteplus-video-generation'`, `'gemini-video-generation'`, `'kling-video-generation'` or
|
||||
* `'minimax-video-generation'`. Defaults to the provider that owns `model`, or Together AI when
|
||||
* neither is given.
|
||||
* @property {string} [driver] Same effect as `provider`.
|
||||
* @property {string} [model] Video model id (provider-specific). Defaults to
|
||||
* `'togetherai:wan-ai/wan2.7-t2v'` (Wan 2.7 text-to-video). `'gemini-omni-1.1-flash'` selects
|
||||
|
||||
Reference in new issue
Block a user