diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts index 35886747b..1737ebf66 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts @@ -1001,6 +1001,49 @@ describe('VideoGenerationDriver.generate puter_output_path', () => { // ── Size unification ─────────────────────────────────────────────── describe('VideoGenerationDriver.generate size unification', () => { + it.each(['912x512', '512x912'])( + 'selects and bills Hailuo 02 at 512p for %s', + async (size) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const incrementUsageSpy = vi.spyOn( + server.services.metering, + 'incrementUsage', + ); + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ task_id: 'mm-task' })), + ); + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ + status: 'Success', + file_id: 'file-1', + })), + ); + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ + file: { download_url: 'https://minimax/out.mp4' }, + })), + ); + + await withActor(() => driver.generate({ + prompt: 'a ball rolls', + model: 'minimax-hailuo-02', + size, + input_reference: 'https://example.com/first.png', + } as never)); + + const body = JSON.parse( + fetchSpy.mock.calls[0]![1]!.body as string, + ); + expect(body.resolution).toBe('512P'); + expect(incrementUsageSpy).toHaveBeenCalledWith( + SYSTEM_ACTOR, + 'minimax-video-generation:minimax-hailuo-02:per-video:512p:6', + 1, + 10 * 1_000_000, + ); + }, + ); + it('maps a WIDTHxHEIGHT size onto a tier plus aspect ratio for tier-based models', async () => { completeTogetherJob(); diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 889c24fba..a9da00362 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -66,14 +66,15 @@ const parsePixelSize = ( }; /** Resolution tier of the shorter side, e.g. 1920x1080 and 1080x1920 → 1080p. */ -const tierForPixels = ({ - width, - height, -}: { - width: number; - height: number; -}) => { +const tierForPixels = ( + { width, height }: { width: number; height: number }, + dimensions: readonly string[], +) => { const shortSide = Math.min(width, height); + const exactTier = dimensions.find( + (tier) => tier.toLowerCase() === `${shortSide}p`, + ); + if (exactTier) return exactTier; if (shortSide <= 480) return '480p'; if (shortSide <= 720) return '720p'; if (shortSide <= 1080) return '1080p'; @@ -297,7 +298,7 @@ export class VideoGenerationDriver extends PuterDriver { const catalogIsTiers = model.dimensions.every(isResolutionTier); const wanted = catalogIsTiers && requestedPixels - ? tierForPixels(requestedPixels) + ? tierForPixels(requestedPixels, model.dimensions) : requestedResolution; // Case-insensitive so '4K' matches a catalog entry spelled '4k'; diff --git a/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.test.ts b/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.test.ts index a5dcfb319..b6aed46ec 100644 --- a/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.test.ts +++ b/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.test.ts @@ -384,19 +384,21 @@ describe('MiniMaxVideoProvider.generate Hailuo request shape', () => { model: 'minimax-hailuo-2.3-fast', seconds: 10, size: '1080p', + input_reference: 'https://example.com/first.png', }); expect(sentBody()).toMatchObject({ model: 'MiniMax-Hailuo-2.3-Fast', duration: 6, resolution: '1080P', + first_frame_image: 'https://example.com/first.png', }); }); - it('sends frames as first_frame_image and last_frame_image', async () => { + it.each(['768p', '1080p'])('sends first and last frames at %s', async (size) => { mockHailuoFlow(hailuoTask('Success')); await generate({ model: 'minimax-hailuo-02', - size: '512p', + size, input_reference: 'DDDD', last_frame: 'https://example.com/last.png', }); @@ -404,7 +406,7 @@ describe('MiniMaxVideoProvider.generate Hailuo request shape', () => { model: 'MiniMax-Hailuo-02', prompt: 'a ball rolls', duration: 6, - resolution: '512P', + resolution: size.toUpperCase(), first_frame_image: 'data:image/png;base64,DDDD', last_frame_image: 'https://example.com/last.png', }); @@ -476,6 +478,19 @@ describe('MiniMaxVideoProvider.generate rejects invalid input before calling Min '512p without a first frame on Hailuo 02', { model: 'minimax-hailuo-02', size: '512p' }, ], + [ + 'a missing first frame on Hailuo 2.3 Fast', + { model: 'minimax-hailuo-2.3-fast' }, + ], + [ + '512p with first and last frames on Hailuo 02', + { + model: 'minimax-hailuo-02', + size: '512p', + input_reference: 'https://example.com/first.png', + last_frame: 'https://example.com/last.png', + }, + ], [ 'a non-string image', { model: 'minimax-h3', input_reference: 42 as never }, @@ -485,11 +500,17 @@ describe('MiniMaxVideoProvider.generate rejects invalid input before calling Min { model: 'minimax-h3', reference_images: 'x' as never }, ], ])('rejects %s', async (_label, params) => { + fetchSpy.mockRejectedValue(new Error('Unexpected MiniMax request')); + const reserveCreditsSpy = vi.spyOn( + server.services.metering, + 'reserveCredits', + ); await expect(generate(params)).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request', }); expect(fetchSpy).not.toHaveBeenCalled(); + expect(reserveCreditsSpy).not.toHaveBeenCalled(); }); it('accepts a 7000-character prompt on H3', async () => { diff --git a/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.ts b/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.ts index 7a56e2a4a..5903d1290 100644 --- a/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.ts +++ b/src/backend/drivers/ai-video/providers/minimax/MiniMaxVideoProvider.ts @@ -529,6 +529,9 @@ export class MiniMaxVideoProvider extends VideoProvider { }, ) { const { firstFrame, endFrame, references } = inputs; + if (spec.firstFrameRequired && !firstFrame) { + throw badRequest(`${model.id} requires an input_reference image`); + } if (endFrame && !spec.lastFrame) { throw badRequest(`${model.id} does not support last_frame`); } @@ -556,9 +559,12 @@ export class MiniMaxVideoProvider extends VideoProvider { `${model.id} takes at most ${spec.maxImages} image(s), frames and references together`, ); } - if (spec.imageOnlyResolutions?.includes(resolutionKey) && !firstFrame) { + if ( + spec.imageOnlyResolutions?.includes(resolutionKey) && + (!firstFrame || endFrame) + ) { throw badRequest( - `${model.id} renders ${resolutionKey} only from an input_reference image`, + `${model.id} renders ${resolutionKey} only from an input_reference image without last_frame`, ); } } diff --git a/src/backend/drivers/ai-video/providers/minimax/models.ts b/src/backend/drivers/ai-video/providers/minimax/models.ts index 154c52307..f4fecb6a0 100644 --- a/src/backend/drivers/ai-video/providers/minimax/models.ts +++ b/src/backend/drivers/ai-video/providers/minimax/models.ts @@ -35,6 +35,7 @@ export interface MiniMaxVideoSpec { maxImages: number; /** Whether `reference_images` are taken (H3 reference-to-video). */ references: boolean; + firstFrameRequired?: boolean; lastFrame: boolean; /** Resolutions MiniMax renders only from a first frame. */ imageOnlyResolutions?: string[]; @@ -217,6 +218,7 @@ export const MINIMAX_VIDEO_SPECS: Record = { api: 'v1', maxImages: 1, references: false, + firstFrameRequired: true, lastFrame: false, maxPromptChars: 2000, }, diff --git a/src/docs/src/AI/txt2vid.md b/src/docs/src/AI/txt2vid.md index d37e58940..afc97fc13 100644 --- a/src/docs/src/AI/txt2vid.md +++ b/src/docs/src/AI/txt2vid.md @@ -289,7 +289,7 @@ Available when using a MiniMax model (provider `'minimax-video-generation'`). Th | `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. +The first resolution listed is the default. "Images in total" counts `input_reference`, `last_frame` and `reference_images` together. Hailuo 2.3 Fast requires an `input_reference` image. Hailuo 02 supports `512p` with a first frame alone. First and last frames together require `768p` or `1080p`. | Model | Price | |-------|-------| @@ -308,8 +308,8 @@ The first resolution listed is the default. "Images in total" counts `input_refe | `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 | +| `input_reference` | `String \| Blob` | Image used as the first frame, in any [image input](#image-inputs) format. Required on Hailuo 2.3 Fast. 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` and `768p` or `1080p` on Hailuo 02. The H3 models also take it alone | | `reference_images` | `Array` | 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. diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index cb3b03eb8..96ac19400 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -139,7 +139,7 @@ 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. +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). Hailuo 2.3 Fast requires a first-frame image. Hailuo 02 supports first and last frames together at `768p` or `1080p`, and a first frame alone at `512p`. 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.