From 0c4affd462845f19cb7956801762ae4d48338a88 Mon Sep 17 00:00:00 2001 From: kapelame Date: Sat, 1 Aug 2026 13:51:52 +0800 Subject: [PATCH] feat: add Hailuo H3 video generation support --- README.md | 12 + README_CN.md | 12 + SDK.md | 17 + skill/SKILL.md | 24 +- src/client/endpoints.ts | 8 + src/command.ts | 1 + src/commands/video/generate.ts | 263 ++++++++++++---- src/commands/video/task-get.ts | 34 +- src/registry.ts | 7 +- src/sdk/video/index.ts | 144 ++++++++- src/types/api.ts | 75 +++++ src/types/commands.ts | 9 + src/utils/image.ts | 22 +- src/utils/media.ts | 72 +++++ src/utils/schema.ts | 1 + src/video/v2.ts | 205 ++++++++++++ test/commands/config/export-schema.test.ts | 8 + test/commands/video/generate.test.ts | 348 ++++++++++++++++++++- test/commands/video/task-get.test.ts | 58 ++++ test/sdk/video.test.ts | 151 +++++++++ test/utils/media.test.ts | 56 ++++ test/video/v2.test.ts | 199 ++++++++++++ 22 files changed, 1626 insertions(+), 100 deletions(-) create mode 100644 src/utils/media.ts create mode 100644 src/video/v2.ts create mode 100644 test/utils/media.test.ts create mode 100644 test/video/v2.test.ts diff --git a/README.md b/README.md index ddeafd8b..e0326237 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,24 @@ mmx image generate --prompt "Logo" --out-dir ./out/ ### `mmx video` ```bash +# Hailuo-2.3 (Video Generation V1) mmx video generate --prompt "Ocean waves at sunset" --download sunset.mp4 mmx video generate --prompt "A robot painting" --async + +# MiniMax-H3 (Video Generation V2) +mmx video generate --api-key "$MINIMAX_API_KEY" --model MiniMax-H3 --prompt "Ocean waves at sunset" +mmx video generate --api-key "$MINIMAX_API_KEY" --model MiniMax-H3 --prompt "The subject walks forward" --image start.jpg +mmx video generate --api-key "$MINIMAX_API_KEY" --model MiniMax-H3 --prompt "Keep the same character" --reference-image character.png --reference-video motion.mp4 + mmx video task get --task-id 123456 +mmx video task get --task-id 424010985738629 --model MiniMax-H3 mmx video download --file-id 176844028768320 --out video.mp4 ``` +For MiniMax-H3, local files and Base64 data URIs are preflight-checked against the documented limits: image 30 MB, reference video 50 MB, reference audio 15 MB, and total JSON request body 64 MB. The API validates dimensions, aspect ratio, duration, frame rate, and codecs for all media. + +`--region` is an existing global CLI option, not an H3 parameter. Normal video generation does not need it; the CLI uses the saved or automatically detected region. H3 defaults to 2K, 5 seconds, and 16:9 for text-to-video. + ### `mmx speech` ```bash diff --git a/README_CN.md b/README_CN.md index db48fb4c..0b7c5f0c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -85,12 +85,24 @@ mmx image generate --prompt "山水画" --out-dir ./output/ ### `mmx video` ```bash +# Hailuo-2.3(视频生成 V1) mmx video generate --prompt "海浪拍打礁石" --download sunset.mp4 mmx video generate --prompt "机器人作画" --async + +# MiniMax-H3(视频生成 V2) +mmx video generate --api-key "$MINIMAX_API_KEY" --model MiniMax-H3 --prompt "夕阳下的海浪" +mmx video generate --api-key "$MINIMAX_API_KEY" --model MiniMax-H3 --prompt "主体向前行走" --image start.jpg +mmx video generate --api-key "$MINIMAX_API_KEY" --model MiniMax-H3 --prompt "保持相同角色和动作" --reference-image character.png --reference-video motion.mp4 + mmx video task get --task-id 123456 +mmx video task get --task-id 424010985738629 --model MiniMax-H3 mmx video download --file-id 176844028768320 --out video.mp4 ``` +MiniMax-H3 会在发送前检查本地文件和 Base64 数据:图片不超过 30 MB、参考视频不超过 50 MB、参考音频不超过 15 MB、JSON 请求体总计不超过 64 MB。所有素材的尺寸、宽高比、时长、帧率和编码格式仍由 API 服务端校验。 + +`--region` 是 CLI 原有的全局选项,不是 H3 参数。正常生成视频无需填写,CLI 会使用已保存或自动识别的区域。H3 默认使用 2K、5 秒,文生视频默认比例为 16:9。 + ### `mmx speech` ```bash diff --git a/SDK.md b/SDK.md index 89623759..ed669983 100644 --- a/SDK.md +++ b/SDK.md @@ -65,12 +65,29 @@ const video = await sdk.video.generate({ prompt: 'Ocean waves at sunset', }); +// MiniMax-H3 — Video Generation V2 with request defaults +const h3Video = await sdk.video.generate({ + model: 'MiniMax-H3', + prompt: 'Ocean waves at sunset', +}); + // Asynchronous — returns task ID immediately const { taskId } = await sdk.video.generate({ prompt: 'A robot painting', async: true, }); +const { taskId: h3TaskId } = await sdk.video.generate({ + model: 'MiniMax-H3', + prompt: 'Ocean waves at sunset', + async: true, +}); + +const h3Task = await sdk.video.getTask({ + taskId: h3TaskId, + model: 'MiniMax-H3', +}); + const task = await sdk.video.getTask({ taskId }); // Download diff --git a/skill/SKILL.md b/skill/SKILL.md index 1f5ced1f..a6c3ee8b 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -117,7 +117,7 @@ mmx image generate --prompt "Logo" --n 3 --out-dir ./gen/ --quiet ### video generate -Generate video. Default model: `MiniMax-Hailuo-2.3`. This is an async task — by default it polls until completion. +Generate video. Default model: `MiniMax-Hailuo-2.3`. Select `MiniMax-H3` to use the Video Generation V2 multimodal API. This is an async task — by default it polls until completion. ```bash mmx video generate --prompt [flags] @@ -126,8 +126,14 @@ mmx video generate --prompt [flags] | Flag | Type | Description | |---|---|---| | `--prompt ` | string, **required** | Video description | -| `--model ` | string | `MiniMax-Hailuo-2.3` (default) or `MiniMax-Hailuo-2.3-Fast` | -| `--first-frame ` | string | First frame image | +| `--model ` | string | `MiniMax-Hailuo-2.3` (default), `MiniMax-Hailuo-2.3-Fast`, or `MiniMax-H3` | +| `--image ` | string | Input image for image-to-video | +| `--last-frame ` | string | Last frame image; H3 supports last-frame-only input | +| `--reference-image ` | string, repeatable | H3 reference image | +| `--reference-video ` | string, repeatable | H3 reference video | +| `--reference-audio ` | string, repeatable | H3 reference audio; requires a reference image or video | +| `--duration ` | number | H3 duration, 4-15 seconds (default: 5) | +| `--ratio ` | string | H3 output ratio; T2V cannot use `adaptive` | | `--callback-url ` | string | Webhook URL for completion | | `--download ` | string | Save video to specific file | | `--async` | boolean | Return task ID immediately | @@ -139,6 +145,18 @@ mmx video generate --prompt [flags] mmx video generate --prompt "A robot." --async --quiet # stdout: {"taskId":"..."} +# H3 text-to-video +mmx video generate --model MiniMax-H3 --prompt "Ocean waves." --async --quiet + +# H3 image-to-video +mmx video generate --model MiniMax-H3 --prompt "The subject walks forward." \ + --image start.jpg --async --quiet + +# H3 reference-to-video +mmx video generate --model MiniMax-H3 --prompt "Keep the same character." \ + --reference-image character.png \ + --reference-video motion.mp4 --async --quiet + # Blocking: wait and get file path mmx video generate --prompt "Ocean waves." --download ocean.mp4 --quiet # stdout: ocean.mp4 diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index b9a21196..36497a1d 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -18,10 +18,18 @@ export function videoGenerateEndpoint(baseUrl: string): string { return `${baseUrl}/v1/video_generation`; } +export function videoGenerateV2Endpoint(baseUrl: string): string { + return `${baseUrl}/v2/video_generation`; +} + export function videoTaskEndpoint(baseUrl: string, taskId: string): string { return `${baseUrl}/v1/query/video_generation?task_id=${taskId}`; } +export function videoTaskV2Endpoint(baseUrl: string, taskId: string): string { + return `${baseUrl}/v2/query/video_generation/${taskId}`; +} + export function fileRetrieveEndpoint(baseUrl: string, fileId: string): string { return `${baseUrl}/v1/files/retrieve?file_id=${fileId}`; } diff --git a/src/command.ts b/src/command.ts index 8d89da84..5881103d 100644 --- a/src/command.ts +++ b/src/command.ts @@ -6,6 +6,7 @@ export interface OptionDef { description: string; type?: 'string' | 'number' | 'boolean' | 'array'; required?: boolean; + hidden?: boolean; } export interface Command { diff --git a/src/commands/video/generate.ts b/src/commands/video/generate.ts index 996a3366..3419e6ef 100644 --- a/src/commands/video/generate.ts +++ b/src/commands/video/generate.ts @@ -2,27 +2,55 @@ import { defineCommand } from '../../command'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { requestJson } from '../../client/http'; -import { videoGenerateEndpoint, videoTaskEndpoint, fileRetrieveEndpoint } from '../../client/endpoints'; +import { + fileRetrieveEndpoint, + videoGenerateEndpoint, + videoGenerateV2Endpoint, + videoTaskEndpoint, + videoTaskV2Endpoint, +} from '../../client/endpoints'; import { poll } from '../../polling/poll'; import { downloadFile, formatBytes } from '../../files/download'; import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -import type { VideoRequest, VideoResponse, VideoTaskResponse, FileRetrieveResponse } from '../../types/api'; +import type { + FileRetrieveResponse, + VideoRequest, + VideoResponse, + VideoTaskResponse, + VideoV2Request, + VideoV2Response, + VideoV2TaskResponse, +} from '../../types/api'; import { resolveImageInput } from '../../utils/image'; +import { resolveMediaInput, VIDEO_V2_MEDIA_SIZE_LIMITS } from '../../utils/media'; import { promptOrFail } from '../../utils/prompt'; +import { + buildVideoV2Request, + isVideoV2Model, + isVideoV2Request, + VIDEO_V2_MODEL, + VideoV2InputError, +} from '../../video/v2'; export default defineCommand({ name: 'video generate', - description: 'Generate a video\n T2V: Hailuo-2.3\n I2V: Hailuo-2.3 (default) / Hailuo-2.3-Fast (fast mode, requires --first-frame)\n SEF: Hailuo-02 (requires --first-frame and --last-frame)\n S2V: S2V-01 (requires --subject-image)', - apiDocs: '/docs/api-reference/video-generation', + description: 'Generate a video\n V2: MiniMax-H3 (text/image/video/audio content, 2K)\n T2V: Hailuo-2.3\n I2V: Hailuo-2.3 (default) / Hailuo-2.3-Fast (fast mode, requires --image)\n SEF: Hailuo-02 (requires --image and --last-frame)\n S2V: S2V-01 (requires --subject-image)', + apiDocs: '/docs/api-reference/video-generation-v2-create', usage: 'mmx video generate --prompt [flags]', options: [ - { flag: '--model ', description: 'Model ID. T2V: MiniMax-Hailuo-2.3; I2V: MiniMax-Hailuo-2.3 (default) or MiniMax-Hailuo-2.3-Fast (fast, requires --first-frame). Auto-switched to Hailuo-02 with --last-frame, or S2V-01 with --subject-image.' }, + { flag: '--model ', description: 'Model ID. V2: MiniMax-H3. Legacy: MiniMax-Hailuo-2.3 or MiniMax-Hailuo-2.3-Fast. Auto-switched to Hailuo-02 with --last-frame, or S2V-01 with --subject-image.' }, { flag: '--prompt ', description: 'Video description', required: true }, - { flag: '--first-frame ', description: 'First frame image (local path or URL). Auto base64-encoded for local files.' }, - { flag: '--last-frame ', description: 'Last frame image (local path or URL). Enables SEF (start-end frame) interpolation mode with Hailuo-02 model. Requires --first-frame.' }, + { flag: '--image ', description: 'Input image for image-to-video (local path or URL).' }, + { flag: '--first-frame ', description: 'Backward-compatible alias for --image.', hidden: true }, + { flag: '--last-frame ', description: 'Optional ending image. Legacy SEF also requires --image; MiniMax-H3 supports a last frame alone.' }, { flag: '--subject-image ', description: 'Subject reference image for character consistency (local path or URL). Switches to S2V-01 model.' }, + { flag: '--reference-image ', description: 'H3 reference image (repeatable).', type: 'array' }, + { flag: '--reference-video ', description: 'H3 reference video (repeatable; local MP4, URL, data URI, or mm_file:// ID).', type: 'array' }, + { flag: '--reference-audio ', description: 'H3 reference audio (repeatable; requires a reference image or video).', type: 'array' }, + { flag: '--duration ', description: 'Output duration. H3 supports integer values from 4 to 15 (default: 5).', type: 'number' }, + { flag: '--ratio ', description: 'H3 aspect ratio: adaptive, 21:9, 16:9, 4:3, 1:1, 3:4, or 9:16.' }, { flag: '--callback-url ', description: 'Webhook URL for completion notification' }, { flag: '--download ', description: 'Save video to file on completion' }, { flag: '--no-wait', description: 'Return task ID immediately without waiting' }, @@ -34,8 +62,14 @@ export default defineCommand({ 'mmx video generate --prompt "Ocean waves at sunset." --download sunset.mp4', 'mmx video generate --prompt "A robot painting." --async --quiet', 'mmx video generate --prompt "A robot painting." --no-wait --quiet', + '# H3 text-to-video (Video Generation V2)', + 'mmx video generate --model MiniMax-H3 --prompt "Ocean waves at sunset"', + '# H3 image-to-video', + 'mmx video generate --model MiniMax-H3 --prompt "The subject walks forward" --image start.jpg', + '# H3 reference-to-video', + 'mmx video generate --model MiniMax-H3 --prompt "Keep the same character" --reference-image character.png --reference-video motion.mp4', '# SEF: first + last frame interpolation (uses Hailuo-02 model)', - 'mmx video generate --prompt "Walk forward" --first-frame start.jpg --last-frame end.jpg', + 'mmx video generate --prompt "Walk forward" --image start.jpg --last-frame end.jpg', '# Subject reference: character consistency (uses S2V-01 model)', 'mmx video generate --prompt "A detective walking" --subject-image character.jpg', ], @@ -51,73 +85,148 @@ export default defineCommand({ nonInteractive: config.nonInteractive, }); - // Validate mutually exclusive mode flags - if (flags.lastFrame && flags.subjectImage) { - throw new CLIError( - '--last-frame and --subject-image cannot be used together (SEF and S2V are different modes).', - ExitCode.USAGE, - 'mmx video generate --prompt --first-frame --last-frame ', - ); - } - - // MiniMax-Hailuo-2.3-Fast only supports I2V, not T2V const explicitModel = flags.model as string | undefined; - if (explicitModel === 'MiniMax-Hailuo-2.3-Fast' && !flags.firstFrame) { + const configuredModel = config.defaultVideoModel || 'MiniMax-Hailuo-2.3'; + const image = flags.image as string | undefined; + const legacyFirstFrame = flags.firstFrame as string | undefined; + if (image && legacyFirstFrame) { throw new CLIError( - 'MiniMax-Hailuo-2.3-Fast only supports I2V (image-to-video). Use --first-frame to provide an input image.', + '--image and --first-frame are aliases; provide only one.', ExitCode.USAGE, - 'mmx video generate --prompt --model MiniMax-Hailuo-2.3-Fast --first-frame ', ); } + const inputImage = image ?? legacyFirstFrame; + const hasV2OnlyInput = Boolean( + flags.referenceImage || + flags.referenceVideo || + flags.referenceAudio || + flags.duration !== undefined || + flags.ratio, + ); - // Determine model: explicit --model > auto-switch > config default > hardcoded + // Determine model: explicit --model > configured H3 default > legacy auto-switch > default let model: string; if (explicitModel) { model = explicitModel; + } else if (isVideoV2Model(configuredModel)) { + model = VIDEO_V2_MODEL; } else if (flags.lastFrame) { model = 'MiniMax-Hailuo-02'; } else if (flags.subjectImage) { model = 'S2V-01'; } else { - model = config.defaultVideoModel || 'MiniMax-Hailuo-2.3'; + model = configuredModel; } - const body: VideoRequest = { - model, - prompt, - }; + if (flags.lastFrame && flags.subjectImage) { + throw new CLIError( + '--last-frame and --subject-image cannot be used together (SEF and S2V are different modes).', + ExitCode.USAGE, + 'mmx video generate --prompt --image --last-frame ', + ); + } - // First frame (I2V) - if (flags.firstFrame) { - body.first_frame_image = resolveImageInput(flags.firstFrame as string); + // MiniMax-Hailuo-2.3-Fast only supports I2V, not T2V + if (explicitModel === 'MiniMax-Hailuo-2.3-Fast' && !inputImage) { + throw new CLIError( + 'MiniMax-Hailuo-2.3-Fast only supports I2V (image-to-video). Use --image to provide an input image.', + ExitCode.USAGE, + 'mmx video generate --prompt --model MiniMax-Hailuo-2.3-Fast --image ', + ); } - // Last frame (SEF mode) - if (flags.lastFrame) { - if (!flags.firstFrame) { + let body: VideoRequest | VideoV2Request; + + if (isVideoV2Model(model)) { + if (flags.subjectImage) { throw new CLIError( - '--last-frame requires --first-frame (SEF mode).', + '--subject-image is only supported by the legacy S2V-01 model. Use --reference-image for MiniMax-H3.', ExitCode.USAGE, - 'mmx video generate --prompt --first-frame --last-frame ', + 'mmx video generate --model MiniMax-H3 --prompt --reference-image ', ); } - body.last_frame_image = resolveImageInput(flags.lastFrame as string); - } - // Subject reference (S2V mode) - if (flags.subjectImage) { - body.subject_reference = [{ type: 'character', image: [resolveImageInput(flags.subjectImage as string)] }]; - } + const images = [ + ...(inputImage + ? [{ + url: resolveImageInput(inputImage, VIDEO_V2_MEDIA_SIZE_LIMITS.image), + role: 'first_frame' as const, + }] + : []), + ...(flags.lastFrame + ? [{ + url: resolveImageInput(flags.lastFrame as string, VIDEO_V2_MEDIA_SIZE_LIMITS.image), + role: 'last_frame' as const, + }] + : []), + ...((flags.referenceImage as string[] | undefined) ?? []).map(input => ({ + url: resolveImageInput(input, VIDEO_V2_MEDIA_SIZE_LIMITS.image), + role: 'reference_image' as const, + })), + ]; - if (flags.callbackUrl) { - body.callback_url = flags.callbackUrl as string; + try { + body = buildVideoV2Request({ + prompt, + images, + referenceVideos: ((flags.referenceVideo as string[] | undefined) ?? []) + .map(input => resolveMediaInput(input, 'video')), + referenceAudios: ((flags.referenceAudio as string[] | undefined) ?? []) + .map(input => resolveMediaInput(input, 'audio')), + duration: flags.duration as number | undefined, + ratio: flags.ratio as string | undefined, + callbackUrl: flags.callbackUrl as string | undefined, + }); + } catch (error) { + if (error instanceof VideoV2InputError) { + throw new CLIError( + error.message, + ExitCode.USAGE, + 'mmx video generate --model MiniMax-H3 --prompt ', + ); + } + throw error; + } + } else { + if (hasV2OnlyInput) { + throw new CLIError( + '--reference-image, --reference-video, --reference-audio, --duration, and --ratio require --model MiniMax-H3.', + ExitCode.USAGE, + ); + } + + body = { model, prompt }; + + if (inputImage) { + body.first_frame_image = resolveImageInput(inputImage); + } + + if (flags.lastFrame) { + if (!inputImage) { + throw new CLIError( + '--last-frame requires --image (SEF mode).', + ExitCode.USAGE, + 'mmx video generate --prompt --image --last-frame ', + ); + } + body.last_frame_image = resolveImageInput(flags.lastFrame as string); + } + + if (flags.subjectImage) { + body.subject_reference = [{ type: 'character', image: [resolveImageInput(flags.subjectImage as string)] }]; + } + + if (flags.callbackUrl) body.callback_url = flags.callbackUrl as string; } if (dryRun(config, body)) return; const format = detectOutputFormat(config.output); - const url = videoGenerateEndpoint(config.baseUrl); - const response = await requestJson(config, { + const usesV2 = isVideoV2Request(body); + const url = usesV2 + ? videoGenerateV2Endpoint(config.baseUrl) + : videoGenerateEndpoint(config.baseUrl); + const response = await requestJson(config, { url, method: 'POST', body, @@ -138,29 +247,47 @@ export default defineCommand({ // Default: poll until completion const pollInterval = (flags.pollInterval as number) ?? 5; - const taskUrl = videoTaskEndpoint(config.baseUrl, taskId); - - const result = await poll(config, { - url: taskUrl, - intervalSec: pollInterval, - timeoutSec: config.timeout, - isComplete: (d) => (d as VideoTaskResponse).status === 'Success', - isFailed: (d) => (d as VideoTaskResponse).status === 'Failed', - getStatus: (d) => (d as VideoTaskResponse).status, - }); + let downloadUrl: string | undefined; + let fileId: string | undefined; + let status: string; - if (!result.file_id) { - throw new CLIError( - 'Task completed but no file_id returned.', - ExitCode.GENERAL, - ); - } + if (usesV2) { + const taskUrl = videoTaskV2Endpoint(config.baseUrl, taskId); + const result = await poll(config, { + url: taskUrl, + intervalSec: pollInterval, + timeoutSec: config.timeout, + isComplete: (d) => (d as VideoV2TaskResponse).task.status === 'succeeded', + isFailed: (d) => ['failed', 'cancelled', 'expired'].includes((d as VideoV2TaskResponse).task.status), + getStatus: (d) => (d as VideoV2TaskResponse).task.status, + }); + status = result.task.status; + downloadUrl = result.task.content?.url; + } else { + const taskUrl = videoTaskEndpoint(config.baseUrl, taskId); + const result = await poll(config, { + url: taskUrl, + intervalSec: pollInterval, + timeoutSec: config.timeout, + isComplete: (d) => (d as VideoTaskResponse).status === 'Success', + isFailed: (d) => (d as VideoTaskResponse).status === 'Failed', + getStatus: (d) => (d as VideoTaskResponse).status, + }); + status = result.status; + fileId = result.file_id; - // Resolve file_id to download URL - const fileInfo = await requestJson(config, { - url: fileRetrieveEndpoint(config.baseUrl, result.file_id), - }); - const downloadUrl = fileInfo.file?.download_url; + if (!fileId) { + throw new CLIError( + 'Task completed but no file_id returned.', + ExitCode.GENERAL, + ); + } + + const fileInfo = await requestJson(config, { + url: fileRetrieveEndpoint(config.baseUrl, fileId), + }); + downloadUrl = fileInfo.file?.download_url; + } if (!downloadUrl) { throw new CLIError( @@ -179,8 +306,8 @@ export default defineCommand({ } else { console.log(formatOutput({ task_id: taskId, - status: 'Success', - file_id: result.file_id, + status, + file_id: fileId, saved: destPath, size: formatBytes(size), }, format)); diff --git a/src/commands/video/task-get.ts b/src/commands/video/task-get.ts index d757821f..0b9e0007 100644 --- a/src/commands/video/task-get.ts +++ b/src/commands/video/task-get.ts @@ -2,11 +2,12 @@ import { defineCommand } from '../../command'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { requestJson } from '../../client/http'; -import { videoTaskEndpoint } from '../../client/endpoints'; +import { videoTaskEndpoint, videoTaskV2Endpoint } from '../../client/endpoints'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -import type { VideoTaskResponse } from '../../types/api'; +import type { VideoTaskResponse, VideoV2TaskResponse } from '../../types/api'; +import { isVideoV2Model } from '../../video/v2'; export default defineCommand({ name: 'video task get', @@ -14,10 +15,12 @@ export default defineCommand({ usage: 'mmx video task get --task-id ', options: [ { flag: '--task-id ', description: 'Video generation task ID' }, + { flag: '--model ', description: 'Use MiniMax-H3 for Video Generation V2 tasks; defaults to the legacy V1 query.' }, ], examples: [ 'mmx video task get --task-id 106916112212032', 'mmx video task get --task-id 106916112212032 --output json', + 'mmx video task get --task-id 424010985738629 --model MiniMax-H3 --output json', ], async run(config: Config, flags: GlobalFlags) { const taskId = flags.taskId as string | undefined; @@ -34,9 +37,34 @@ export default defineCommand({ return; } + const format = detectOutputFormat(config.output); + const model = flags.model as string | undefined; + + if (isVideoV2Model(model)) { + const url = videoTaskV2Endpoint(config.baseUrl, taskId); + const response = await requestJson(config, { url }); + const task = response.task; + + if (config.quiet) { + console.log(task.status); + return; + } + + console.log(formatOutput({ + task_id: task.id, + model: task.model, + status: task.status, + url: task.content?.url, + resolution: task.resolution, + duration: task.duration, + ratio: task.ratio, + error: task.error, + }, format)); + return; + } + const url = videoTaskEndpoint(config.baseUrl, taskId); const response = await requestJson(config, { url }); - const format = detectOutputFormat(config.output); if (config.quiet) { console.log(response.status); diff --git a/src/registry.ts b/src/registry.ts index feca2332..d34b6b10 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -243,10 +243,11 @@ ${b('Getting Help:')} out.write(`\n${cmd.description}\n`); if (cmd.usage) out.write(`${b('Usage:')} ${cmd.usage}\n`); - if (cmd.options && cmd.options.length > 0) { - const maxLen = Math.max(...cmd.options.map(o => o.flag.length)); + const visibleOptions = cmd.options?.filter(option => !option.hidden) ?? []; + if (visibleOptions.length > 0) { + const maxLen = Math.max(...visibleOptions.map(o => o.flag.length)); out.write(`\n${b('Options:')}\n`); - for (const opt of cmd.options) { + for (const opt of visibleOptions) { out.write(` ${a(opt.flag.padEnd(maxLen + 2))} ${d(opt.description)}\n`); } } diff --git a/src/sdk/video/index.ts b/src/sdk/video/index.ts index e70ebab0..47491cd6 100644 --- a/src/sdk/video/index.ts +++ b/src/sdk/video/index.ts @@ -1,18 +1,47 @@ import { Client } from "../client"; -import { fileRetrieveEndpoint, videoGenerateEndpoint, videoTaskEndpoint } from "../../client/endpoints"; -import { FileRetrieveResponse, VideoRequest, VideoResponse, VideoTaskResponse } from "../../types/api"; +import { + fileRetrieveEndpoint, + videoGenerateEndpoint, + videoGenerateV2Endpoint, + videoTaskEndpoint, + videoTaskV2Endpoint, +} from "../../client/endpoints"; +import { + FileRetrieveResponse, + VideoRequest, + VideoTaskResponse, + VideoV2Request, + VideoV2Response, + VideoV2Task, + VideoV2TaskResponse, +} from "../../types/api"; import { ModelPartial } from "../types"; import { poll } from "../../polling/poll"; import { downloadFile } from "../../files/download"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; -import { toMerged } from 'es-toolkit/object'; +import { + buildVideoV2Request, + isVideoV2Model, + isVideoV2Request, + validateVideoV2Request, + VIDEO_V2_MODEL, + VideoV2InputError, +} from '../../video/v2'; -export interface VideoAsyncGenerateRequest extends ModelPartial { +export type VideoV2GenerateRequest = Omit & { + model?: VideoV2Request['model']; + resolution?: VideoV2Request['resolution']; + duration?: VideoV2Request['duration']; +}; + +export type VideoGenerateRequest = ModelPartial | VideoV2GenerateRequest; + +export type VideoAsyncGenerateRequest = VideoGenerateRequest & { async?: boolean; pollInterval?: number; timeout?: number; -} +}; export interface VideoDownloadRequest { fileId: string; @@ -21,11 +50,14 @@ export interface VideoDownloadRequest { export class VideoSDK extends Client { async generate(request: VideoAsyncGenerateRequest & { async: true }): Promise<{taskId: string}>; - async generate(request: ModelPartial): Promise; - async generate(request: VideoAsyncGenerateRequest): Promise { + async generate(request: VideoAsyncGenerateRequest): Promise; + async generate(request: VideoAsyncGenerateRequest): Promise { const body = this.validateParams(request); - const url = videoGenerateEndpoint(this.config.baseUrl); - const res = await this.requestJson({ + const usesV2 = isVideoV2Request(body); + const url = usesV2 + ? videoGenerateV2Endpoint(this.config.baseUrl) + : videoGenerateEndpoint(this.config.baseUrl); + const res = await this.requestJson({ url, method: "POST", body, @@ -36,8 +68,21 @@ export class VideoSDK extends Client { return {taskId}; } + if (usesV2) { + const taskUrl = videoTaskV2Endpoint(this.config.baseUrl, taskId); + const result = await poll(this.config, { + url: taskUrl, + intervalSec: request.pollInterval ?? 5, + timeoutSec: request.timeout ?? this.config.timeout, + isComplete: (d) => (d as VideoV2TaskResponse).task.status === 'succeeded', + isFailed: (d) => ['failed', 'cancelled', 'expired'].includes((d as VideoV2TaskResponse).task.status), + getStatus: (d) => (d as VideoV2TaskResponse).task.status, + }); + return result.task; + } + const taskUrl = videoTaskEndpoint(this.config.baseUrl, taskId); - const result = await poll(this.config, { + return await poll(this.config, { url: taskUrl, intervalSec: request.pollInterval ?? 5, timeoutSec: request.timeout ?? this.config.timeout, @@ -45,11 +90,15 @@ export class VideoSDK extends Client { isFailed: (d) => (d as VideoTaskResponse).status === 'Failed', getStatus: (d) => (d as VideoTaskResponse).status, }); - - return result; } - async getTask({taskId}: {taskId: string}): Promise { + async getTask({taskId, model}: {taskId: string; model?: string}): Promise { + if (isVideoV2Model(model)) { + const url = videoTaskV2Endpoint(this.config.baseUrl, taskId); + const result = await this.requestJson({ url }); + return result.task; + } + const url = videoTaskEndpoint(this.config.baseUrl, taskId); return await this.requestJson({ url }); } @@ -69,8 +118,68 @@ export class VideoSDK extends Client { } } - private validateParams(request: VideoAsyncGenerateRequest): VideoRequest { - const { prompt, model, first_frame_image, last_frame_image, subject_reference } = request; + private validateParams(request: VideoAsyncGenerateRequest): VideoRequest | VideoV2Request { + const params = { ...request } as Record; + delete params.async; + delete params.pollInterval; + delete params.timeout; + + if ('content' in params || isVideoV2Model(params.model as string | undefined)) { + try { + if ('content' in params) { + if (params.model && !isVideoV2Model(params.model as string)) { + throw new VideoV2InputError('content is only supported with model MiniMax-H3'); + } + const content = params.content as VideoV2Request['content']; + const hasFrameInput = content.some(item => + item.type === 'image_url' && (!item.role || item.role === 'first_frame' || item.role === 'last_frame'), + ); + const hasReferenceInput = content.some(item => + item.type !== 'text' && item.role?.startsWith('reference_'), + ); + const body = { + ...params, + model: VIDEO_V2_MODEL, + resolution: (params.resolution ?? '2K') as '2K', + duration: (params.duration ?? 5) as VideoV2Request['duration'], + ratio: params.ratio ?? (hasFrameInput || hasReferenceInput ? 'adaptive' : '16:9'), + } as VideoV2Request; + validateVideoV2Request(body); + return body; + } + + const prompt = params.prompt as string | undefined; + if (!prompt) { + throw new VideoV2InputError('prompt or content is required'); + } + return buildVideoV2Request({ + prompt, + images: [ + ...(params.first_frame_image + ? [{ url: params.first_frame_image as string, role: 'first_frame' as const }] + : []), + ...(params.last_frame_image + ? [{ url: params.last_frame_image as string, role: 'last_frame' as const }] + : []), + ], + resolution: params.resolution as string | undefined, + duration: params.duration as number | undefined, + ratio: params.ratio as string | undefined, + callbackUrl: params.callback_url as string | undefined, + }); + } catch (error) { + if (error instanceof VideoV2InputError) { + throw new SDKError(error.message, ExitCode.USAGE); + } + throw error; + } + } + + if ('resolution' in params || 'duration' in params || 'ratio' in params) { + throw new SDKError('resolution, duration, and ratio require model MiniMax-H3', ExitCode.USAGE); + } + + const { prompt, model, first_frame_image, last_frame_image, subject_reference } = params as ModelPartial; if (!prompt) { throw new SDKError('prompt is required', ExitCode.USAGE); @@ -107,8 +216,9 @@ export class VideoSDK extends Client { ); } - return toMerged({ + return { + ...(params as Omit), model: resolvedModel, - }, request) + }; } } diff --git a/src/types/api.ts b/src/types/api.ts index ffe6af51..c23712c2 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -202,6 +202,81 @@ export interface VideoTaskResponse { video_height?: number; } +export type VideoV2ImageRole = 'first_frame' | 'last_frame' | 'reference_image'; +export type VideoV2Ratio = 'adaptive' | '21:9' | '16:9' | '4:3' | '1:1' | '3:4' | '9:16'; +export type VideoV2Duration = 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; + +export interface VideoV2TextContent { + type: 'text'; + text: string; +} + +export interface VideoV2ImageContent { + type: 'image_url'; + image_url: { url: string }; + role?: VideoV2ImageRole; +} + +export interface VideoV2VideoContent { + type: 'video_url'; + video_url: { url: string }; + role: 'reference_video'; +} + +export interface VideoV2AudioContent { + type: 'audio_url'; + audio_url: { url: string }; + role: 'reference_audio'; +} + +export type VideoV2ContentItem = + | VideoV2TextContent + | VideoV2ImageContent + | VideoV2VideoContent + | VideoV2AudioContent; + +export interface VideoV2Request { + model: 'MiniMax-H3'; + content: VideoV2ContentItem[]; + resolution: '2K'; + duration: VideoV2Duration; + ratio?: VideoV2Ratio; + callback_url?: string; +} + +export interface VideoV2Response { + task_id: string; +} + +export interface VideoV2Task { + id: string; + model: string; + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'expired'; + error?: { + code?: string; + message?: string; + }; + created_at?: number; + updated_at?: number; + content?: { + url?: string; + }; + resolution?: string; + duration?: number; + usage?: { + total_seconds?: number; + input_seconds?: number; + output_seconds?: number; + input_image_count?: number; + }; + ratio?: string; + task_type?: string; +} + +export interface VideoV2TaskResponse { + task: VideoV2Task; +} + // ---- Music ---- export interface MusicRequest { diff --git a/src/types/commands.ts b/src/types/commands.ts index 3efa5238..fd6de8e4 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -43,7 +43,15 @@ export interface ImageGenerateFlags { export interface VideoGenerateFlags { model?: string; prompt?: string; + image?: string; firstFrame?: string; + lastFrame?: string; + subjectImage?: string; + referenceImage?: string[]; + referenceVideo?: string[]; + referenceAudio?: string[]; + duration?: number; + ratio?: string; callbackUrl?: string; wait?: boolean; pollInterval?: number; @@ -52,6 +60,7 @@ export interface VideoGenerateFlags { export interface VideoTaskGetFlags { taskId?: string; + model?: string; } export interface VideoDownloadFlags { diff --git a/src/utils/image.ts b/src/utils/image.ts index f19ec772..ab53b632 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync } from 'fs'; +import { readFileSync, existsSync, statSync } from 'fs'; import { extname } from 'path'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; @@ -8,17 +8,31 @@ export const IMAGE_MIME_TYPES: Record = { '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', }; -export function localFileToDataUri(filePath: string): string { +export function localFileToDataUri(filePath: string, maxBytes?: number): string { + if (maxBytes !== undefined) { + const size = statSync(filePath).size; + if (size > maxBytes) { + throw new CLIError( + `Image file is ${(size / 1024 / 1024).toFixed(1)} MB; MiniMax-H3 allows at most ${maxBytes / 1024 / 1024} MB.`, + ExitCode.USAGE, + 'Use a public URL or mm_file:// file ID for large images.', + ); + } + } const ext = extname(filePath).toLowerCase(); const mime = IMAGE_MIME_TYPES[ext] || 'image/jpeg'; const data = readFileSync(filePath); return `data:${mime};base64,${data.toString('base64')}`; } -export function resolveImageInput(input: string): string { - return input.startsWith('http') ? input : localFileToDataUri(input); +export function resolveImageInput(input: string, maxBytes?: number): string { + return input.startsWith('http') || input.startsWith('data:') || input.startsWith('mm_file://') + ? input + : localFileToDataUri(input, maxBytes); } const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; diff --git a/src/utils/media.ts b/src/utils/media.ts new file mode 100644 index 00000000..6d071812 --- /dev/null +++ b/src/utils/media.ts @@ -0,0 +1,72 @@ +import { existsSync, readFileSync, statSync } from 'fs'; +import { extname } from 'path'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; + +const MEDIA_MIME_TYPES = { + video: { + '.mp4': 'video/mp4', + }, + audio: { + '.mp3': 'audio/mp3', + '.wav': 'audio/wav', + }, +} as const; + +export const VIDEO_V2_MEDIA_SIZE_LIMITS = { + image: 30 * 1024 * 1024, + video: 50 * 1024 * 1024, + audio: 15 * 1024 * 1024, +} as const; + +export const VIDEO_V2_MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024; + +export function dataUriDecodedSize(input: string): number | undefined { + const match = /^data:[^;,]+;base64,([\s\S]*)$/.exec(input); + if (!match) return undefined; + + const base64 = match[1]!.replace(/\s/g, ''); + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor(base64.length * 3 / 4) - padding); +} + +export function resolveMediaInput(input: string, kind: keyof typeof MEDIA_MIME_TYPES): string { + if ( + input.startsWith('http://') || + input.startsWith('https://') || + input.startsWith('mm_file://') + ) { + return input; + } + + if (input.startsWith('data:')) { + return kind === 'audio' + ? input.replace(/^data:audio\/mpeg;base64,/, 'data:audio/mp3;base64,') + : input; + } + + if (!existsSync(input)) { + throw new CLIError(`File not found: ${input}`, ExitCode.USAGE); + } + + const ext = extname(input).toLowerCase(); + const mimeTypes = MEDIA_MIME_TYPES[kind] as Record; + const mime = mimeTypes[ext]; + if (!mime) { + const supported = Object.keys(mimeTypes).map(value => value.slice(1)).join(', '); + throw new CLIError(`Unsupported ${kind} format "${ext}". Supported local files: ${supported}`, ExitCode.USAGE); + } + + const maxBytes = VIDEO_V2_MEDIA_SIZE_LIMITS[kind]; + const size = statSync(input).size; + if (size > maxBytes) { + throw new CLIError( + `${kind[0]!.toUpperCase()}${kind.slice(1)} file is ${(size / 1024 / 1024).toFixed(1)} MB; MiniMax-H3 allows at most ${maxBytes / 1024 / 1024} MB.`, + ExitCode.USAGE, + 'Use a public URL or mm_file:// file ID for large media.', + ); + } + + const data = readFileSync(input); + return `data:${mime};base64,${data.toString('base64')}`; +} diff --git a/src/utils/schema.ts b/src/utils/schema.ts index ed911318..35f472d5 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -48,6 +48,7 @@ export function generateToolSchema(cmd: Command): Record { if (cmd.options) { for (const opt of cmd.options) { + if (opt.hidden) continue; const { name, inferredType, isArray } = parseFlag(opt.flag); if (!name) continue; diff --git a/src/video/v2.ts b/src/video/v2.ts new file mode 100644 index 00000000..ac99acbc --- /dev/null +++ b/src/video/v2.ts @@ -0,0 +1,205 @@ +import type { + VideoRequest, + VideoV2ContentItem, + VideoV2Duration, + VideoV2ImageRole, + VideoV2Ratio, + VideoV2Request, +} from '../types/api'; +import { + dataUriDecodedSize, + VIDEO_V2_MAX_REQUEST_BODY_BYTES, + VIDEO_V2_MEDIA_SIZE_LIMITS, +} from '../utils/media'; + +export const VIDEO_V2_MODEL = 'MiniMax-H3' as const; + +const VIDEO_V2_RATIOS = new Set([ + 'adaptive', + '21:9', + '16:9', + '4:3', + '1:1', + '3:4', + '9:16', +]); + +const VIDEO_V2_IMAGE_ROLES = new Set([ + 'first_frame', + 'last_frame', + 'reference_image', +]); + +export class VideoV2InputError extends Error { + constructor(message: string) { + super(message); + this.name = 'VideoV2InputError'; + } +} + +export interface VideoV2ImageInput { + url: string; + role?: VideoV2ImageRole; +} + +export interface BuildVideoV2RequestOptions { + prompt: string; + images?: VideoV2ImageInput[]; + referenceVideos?: string[]; + referenceAudios?: string[]; + resolution?: string; + duration?: number; + ratio?: string; + callbackUrl?: string; +} + +export function isVideoV2Model(model: string | undefined): model is typeof VIDEO_V2_MODEL { + return model === VIDEO_V2_MODEL; +} + +export function isVideoV2Request(request: VideoRequest | VideoV2Request): request is VideoV2Request { + return isVideoV2Model(request.model); +} + +export function buildVideoV2Request(options: BuildVideoV2RequestOptions): VideoV2Request { + const images = options.images ?? []; + if (images.length > 1 && images.some(image => !image.role)) { + throw new VideoV2InputError('Each image must specify a role when multiple images are provided.'); + } + + const content: VideoV2ContentItem[] = [ + { type: 'text', text: options.prompt }, + ...images.map(image => ({ + type: 'image_url' as const, + image_url: { url: image.url }, + role: image.role ?? 'first_frame', + })), + ...(options.referenceVideos ?? []).map(url => ({ + type: 'video_url' as const, + video_url: { url }, + role: 'reference_video' as const, + })), + ...(options.referenceAudios ?? []).map(url => ({ + type: 'audio_url' as const, + audio_url: { url }, + role: 'reference_audio' as const, + })), + ]; + + const hasFrameInput = content.some(item => + item.type === 'image_url' && (item.role === 'first_frame' || item.role === 'last_frame'), + ); + const hasReferenceInput = content.some(item => + item.type !== 'text' && item.role?.startsWith('reference_'), + ); + + const request: VideoV2Request = { + model: VIDEO_V2_MODEL, + content, + resolution: (options.resolution ?? '2K') as '2K', + duration: (options.duration ?? 5) as VideoV2Duration, + ratio: (hasFrameInput + ? 'adaptive' + : options.ratio ?? (hasReferenceInput ? 'adaptive' : '16:9')) as VideoV2Ratio, + }; + + if (options.callbackUrl) request.callback_url = options.callbackUrl; + + validateVideoV2Request(request); + return request; +} + +export function validateVideoV2Request(request: VideoV2Request): void { + if (!isVideoV2Model(request.model)) { + throw new VideoV2InputError(`Video Generation V2 only supports ${VIDEO_V2_MODEL}.`); + } + + const textItems = request.content.filter(item => item.type === 'text'); + if (!textItems.some(item => item.text.trim().length > 0)) { + throw new VideoV2InputError('MiniMax-H3 requires a non-empty text content item.'); + } + if (textItems.some(item => item.text.length > 7000)) { + throw new VideoV2InputError('MiniMax-H3 text content must not exceed 7000 characters.'); + } + + if (request.resolution !== '2K') { + throw new VideoV2InputError('MiniMax-H3 currently only supports 2K resolution.'); + } + if (!Number.isInteger(request.duration) || request.duration < 4 || request.duration > 15) { + throw new VideoV2InputError('MiniMax-H3 duration must be an integer from 4 to 15 seconds.'); + } + if (request.ratio && !VIDEO_V2_RATIOS.has(request.ratio)) { + throw new VideoV2InputError( + 'MiniMax-H3 ratio must be one of: adaptive, 21:9, 16:9, 4:3, 1:1, 3:4, 9:16.', + ); + } + + const images = request.content.filter(item => item.type === 'image_url'); + const videos = request.content.filter(item => item.type === 'video_url'); + const audios = request.content.filter(item => item.type === 'audio_url'); + + if (images.some(item => item.role && !VIDEO_V2_IMAGE_ROLES.has(item.role))) { + throw new VideoV2InputError( + 'MiniMax-H3 image role must be one of: first_frame, last_frame, reference_image.', + ); + } + if (videos.some(item => item.role !== 'reference_video')) { + throw new VideoV2InputError('MiniMax-H3 video role must be reference_video.'); + } + if (audios.some(item => item.role !== 'reference_audio')) { + throw new VideoV2InputError('MiniMax-H3 audio role must be reference_audio.'); + } + + for (const image of images) { + validateDataUriSize(image.image_url.url, 'image'); + } + for (const video of videos) { + validateDataUriSize(video.video_url.url, 'video'); + } + for (const audio of audios) { + validateDataUriSize(audio.audio_url.url, 'audio'); + } + + const requestBodyBytes = Buffer.byteLength(JSON.stringify(request)); + if (requestBodyBytes > VIDEO_V2_MAX_REQUEST_BODY_BYTES) { + throw new VideoV2InputError( + `MiniMax-H3 request body is ${(requestBodyBytes / 1024 / 1024).toFixed(1)} MB; the maximum is 64 MB. Use public URLs or mm_file:// file IDs instead of Base64.`, + ); + } + const firstFrames = images.filter(item => !item.role || item.role === 'first_frame'); + const lastFrames = images.filter(item => item.role === 'last_frame'); + const referenceImages = images.filter(item => item.role === 'reference_image'); + const hasFrameInput = firstFrames.length > 0 || lastFrames.length > 0; + const hasReferenceInput = referenceImages.length > 0 || videos.length > 0 || audios.length > 0; + + if (firstFrames.length > 1 || lastFrames.length > 1) { + throw new VideoV2InputError('MiniMax-H3 accepts at most one first frame and one last frame.'); + } + if (referenceImages.length > 9 || videos.length > 3 || audios.length > 3) { + throw new VideoV2InputError('MiniMax-H3 accepts up to 9 reference images, 3 reference videos, and 3 reference audios.'); + } + if (hasFrameInput && hasReferenceInput) { + throw new VideoV2InputError('MiniMax-H3 frame inputs and reference inputs cannot be used together.'); + } + if (audios.length > 0 && referenceImages.length === 0 && videos.length === 0) { + throw new VideoV2InputError('MiniMax-H3 reference audio requires at least one reference image or reference video.'); + } + + const isTextOnly = request.content.every(item => item.type === 'text'); + if (isTextOnly && (!request.ratio || request.ratio === 'adaptive')) { + throw new VideoV2InputError('MiniMax-H3 text-to-video requires a concrete ratio; adaptive is not supported.'); + } +} + +function validateDataUriSize( + url: string, + kind: keyof typeof VIDEO_V2_MEDIA_SIZE_LIMITS, +): void { + const size = dataUriDecodedSize(url); + const maxBytes = VIDEO_V2_MEDIA_SIZE_LIMITS[kind]; + if (size !== undefined && size > maxBytes) { + throw new VideoV2InputError( + `MiniMax-H3 ${kind} input is ${(size / 1024 / 1024).toFixed(1)} MB; the maximum is ${maxBytes / 1024 / 1024} MB.`, + ); + } +} diff --git a/test/commands/config/export-schema.test.ts b/test/commands/config/export-schema.test.ts index 7a2bd3a6..ac5dec8d 100644 --- a/test/commands/config/export-schema.test.ts +++ b/test/commands/config/export-schema.test.ts @@ -65,6 +65,14 @@ describe('generateToolSchema', () => { expect(schema.name).toBe('mmx_speech_synthesize'); }); + + it('excludes hidden compatibility aliases', () => { + const schema = getSchema('video generate'); + const props = is(schema.input_schema.properties); + + expect(props.image).toBeDefined(); + expect(props.firstFrame).toBeUndefined(); + }); }); describe('registry getAllCommands filtering', () => { diff --git a/test/commands/video/generate.test.ts b/test/commands/video/generate.test.ts index 9845a09c..50e83e46 100644 --- a/test/commands/video/generate.test.ts +++ b/test/commands/video/generate.test.ts @@ -1,11 +1,77 @@ import { describe, it, expect } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { default as generateCommand } from '../../../src/commands/video/generate'; +import type { Config } from '../../../src/config/schema'; +import type { GlobalFlags } from '../../../src/types/flags'; +import { createMockServer } from '../../helpers/mock-server'; + +const h3DryRunConfig: Config = { + apiKey: 'test-key', + region: 'global', + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, +}; + +const baseFlags: GlobalFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, +}; + +async function h3DryRun(flags: Partial): Promise> { + const originalLog = console.log; + let output = ''; + console.log = (message: string) => { output += message; }; + + try { + await generateCommand.execute(h3DryRunConfig, { + ...baseFlags, + model: 'MiniMax-H3', + prompt: 'A cinematic test scene', + ...flags, + }); + return (JSON.parse(output) as { request: Record }).request; + } finally { + console.log = originalLog; + } +} describe('video generate command', () => { it('has correct name', () => { expect(generateCommand.name).toBe('video generate'); }); + it('keeps the H3 CLI surface minimal', () => { + const visibleOptions = generateCommand.options?.filter(option => !option.hidden) ?? []; + const optionFlags = visibleOptions.map(option => option.flag); + const legacyFirstFrame = generateCommand.options?.find( + option => option.flag === '--first-frame ', + ); + + expect(optionFlags).toContain('--reference-image '); + expect(optionFlags).toContain('--image '); + expect(optionFlags).not.toContain('--first-frame '); + expect(optionFlags).not.toContain('--image-role '); + expect(optionFlags).not.toContain('--resolution '); + expect(optionFlags).not.toContain('--region '); + expect(legacyFirstFrame?.hidden).toBe(true); + }); + it('requires prompt', async () => { const config = { apiKey: 'test-key', @@ -102,7 +168,7 @@ describe('video generate command', () => { // Use HTTP URLs to avoid file system read await generateCommand.execute(config, { prompt: 'A cat', - firstFrame: 'https://example.com/first.png', + image: 'https://example.com/first.png', lastFrame: 'https://example.com/last.png', quiet: false, verbose: false, @@ -163,7 +229,285 @@ describe('video generate command', () => { } }); - it('rejects explicit MiniMax-Hailuo-2.3-Fast without --first-frame', async () => { + it('builds a Video Generation V2 request for MiniMax-H3', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await generateCommand.execute(config, { + prompt: 'Ocean waves at sunset', + model: 'MiniMax-H3', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request).toEqual({ + model: 'MiniMax-H3', + content: [{ type: 'text', text: 'Ocean waves at sunset' }], + resolution: '2K', + duration: 5, + ratio: '16:9', + }); + } finally { + console.log = originalLog; + } + }); + + it('maps H3 reference inputs into typed content items', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await generateCommand.execute(config, { + prompt: 'Keep the same character and motion', + model: 'MiniMax-H3', + referenceImage: ['https://example.com/character.png'], + referenceVideo: ['https://example.com/motion.mp4'], + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.content).toEqual([ + { type: 'text', text: 'Keep the same character and motion' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/character.png' }, + role: 'reference_image', + }, + { + type: 'video_url', + video_url: { url: 'https://example.com/motion.mp4' }, + role: 'reference_video', + }, + ]); + expect(parsed.request.ratio).toBe('adaptive'); + } finally { + console.log = originalLog; + } + }); + + it('maps H3 image, last-frame-only, and image-plus-last-frame CLI flags', async () => { + const inputImage = await h3DryRun({ + image: 'https://example.com/first.png', + }); + expect(inputImage.content).toEqual([ + { type: 'text', text: 'A cinematic test scene' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/first.png' }, + role: 'first_frame', + }, + ]); + + const lastFrame = await h3DryRun({ + lastFrame: 'https://example.com/last.png', + }); + expect(lastFrame.content).toEqual([ + { type: 'text', text: 'A cinematic test scene' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/last.png' }, + role: 'last_frame', + }, + ]); + + const firstAndLastFrame = await h3DryRun({ + image: 'https://example.com/first.png', + lastFrame: 'https://example.com/last.png', + }); + expect(firstAndLastFrame.content).toEqual([ + { type: 'text', text: 'A cinematic test scene' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/first.png' }, + role: 'first_frame', + }, + { + type: 'image_url', + image_url: { url: 'https://example.com/last.png' }, + role: 'last_frame', + }, + ]); + }); + + it('keeps --first-frame as a hidden alias for --image', async () => { + const legacyAlias = await h3DryRun({ + firstFrame: 'https://example.com/legacy.png', + }); + expect(legacyAlias.content).toEqual([ + { type: 'text', text: 'A cinematic test scene' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/legacy.png' }, + role: 'first_frame', + }, + ]); + + await expect( + generateCommand.execute(h3DryRunConfig, { + ...baseFlags, + model: 'MiniMax-H3', + prompt: 'A cinematic test scene', + image: 'https://example.com/image.png', + firstFrame: 'https://example.com/legacy.png', + }), + ).rejects.toThrow('--image and --first-frame are aliases'); + }); + + it('polls an H3 task and downloads the direct V2 content URL', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'mmx-h3-download-')); + const outputPath = join(tempDir, 'result.mp4'); + const originalFetch = globalThis.fetch; + const originalLog = console.log; + let pollCount = 0; + let output = ''; + const server = createMockServer({ + routes: { + '/v2/video_generation': () => new Response(JSON.stringify({ task_id: 'h3-download' }), { + headers: { 'Content-Type': 'application/json' }, + }), + '/v2/query/video_generation/h3-download': () => { + pollCount++; + return new Response(JSON.stringify({ + task: { + id: 'h3-download', + model: 'MiniMax-H3', + status: pollCount === 1 ? 'running' : 'succeeded', + ...(pollCount === 1 + ? {} + : { content: { url: `${server.url}/generated.mp4` } }), + }, + }), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + '/generated.mp4': () => new Response('mock-video'), + }, + }); + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + const httpsDownloadUrl = `${server.url.replace('http://', 'https://')}/generated.mp4`; + if (url === httpsDownloadUrl) { + return originalFetch(`${server.url}/generated.mp4`, init); + } + return originalFetch(input, init); + }) as typeof globalThis.fetch; + console.log = (message: string) => { output += message; }; + + try { + await generateCommand.execute({ + ...h3DryRunConfig, + baseUrl: server.url, + quiet: true, + dryRun: false, + }, { + ...baseFlags, + prompt: 'Ocean waves', + model: 'MiniMax-H3', + duration: 5, + ratio: '16:9', + download: outputPath, + pollInterval: 0, + quiet: true, + dryRun: false, + }); + + expect(pollCount).toBe(2); + expect(readFileSync(outputPath, 'utf8')).toBe('mock-video'); + expect(output).toBe(outputPath); + } finally { + globalThis.fetch = originalFetch; + console.log = originalLog; + server.close(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('keeps H3-only parameters out of the MiniMax-Hailuo-2.3 path', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + await expect( + generateCommand.execute(config, { + prompt: 'A cat', + model: 'MiniMax-Hailuo-2.3', + duration: 5, + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }), + ).rejects.toThrow('require --model MiniMax-H3'); + }); + + it('rejects explicit MiniMax-Hailuo-2.3-Fast without --image', async () => { const config = { apiKey: 'test-key', region: 'global' as const, diff --git a/test/commands/video/task-get.test.ts b/test/commands/video/task-get.test.ts index 29046d8d..25707833 100644 --- a/test/commands/video/task-get.test.ts +++ b/test/commands/video/task-get.test.ts @@ -90,4 +90,62 @@ describe('video task get command', () => { console.log = originalLog; } }); + + it('queries MiniMax-H3 task status from the V2 endpoint', async () => { + server = createMockServer({ + routes: { + '/v2/query/video_generation/h3-123': () => jsonResponse({ + task: { + id: 'h3-123', + model: 'MiniMax-H3', + status: 'succeeded', + content: { url: 'https://example.com/video.mp4' }, + resolution: '2K', + duration: 5, + ratio: '16:9', + }, + }), + }, + }); + + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: server.url, + output: 'json' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await taskGetCommand.execute(config, { + taskId: 'h3-123', + model: 'MiniMax-H3', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.status).toBe('succeeded'); + expect(parsed.url).toBe('https://example.com/video.mp4'); + } finally { + console.log = originalLog; + } + }); }); diff --git a/test/sdk/video.test.ts b/test/sdk/video.test.ts index c311d4e4..8db70c3e 100644 --- a/test/sdk/video.test.ts +++ b/test/sdk/video.test.ts @@ -33,6 +33,73 @@ describe('MiniMaxSDK.video', () => { expect(result.taskId).toBe('vid-123'); }); + it('keeps the legacy V1 request payload unchanged', async () => { + let requestText = ''; + server = createMockServer({ + routes: { + 'POST /v1/video_generation': async (req) => { + requestText = await req.text(); + return jsonResponse({ + task_id: 'vid-legacy', + base_resp: { status_code: 0, status_msg: 'success' }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + await sdk.video.generate({ + model: 'MiniMax-Hailuo-2.3', + prompt: 'A cat walking', + first_frame_image: 'https://example.com/first.png', + callback_url: 'https://example.com/callback', + async: true, + }); + + expect(requestText).toBe(JSON.stringify({ + model: 'MiniMax-Hailuo-2.3', + prompt: 'A cat walking', + first_frame_image: 'https://example.com/first.png', + callback_url: 'https://example.com/callback', + })); + }); + + it('should use Video Generation V2 for MiniMax-H3', async () => { + let requestBody: unknown; + server = createMockServer({ + routes: { + '/v2/video_generation': async (req) => { + requestBody = await req.json(); + return jsonResponse({ task_id: 'h3-123' }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.video.generate({ + model: 'MiniMax-H3', + content: [{ type: 'text', text: 'Ocean waves' }], + async: true, + }); + + expect(result.taskId).toBe('h3-123'); + expect(requestBody).toEqual({ + model: 'MiniMax-H3', + content: [{ type: 'text', text: 'Ocean waves' }], + resolution: '2K', + duration: 5, + ratio: '16:9', + }); + }); + it('should get task status', async () => { server = createMockServer({ routes: { @@ -53,6 +120,70 @@ describe('MiniMaxSDK.video', () => { expect(result.status).toBe('Success'); }); + + it('should get MiniMax-H3 task status from Video Generation V2', async () => { + server = createMockServer({ + routes: { + '/v2/query/video_generation/h3-123': () => jsonResponse({ + task: { + id: 'h3-123', + model: 'MiniMax-H3', + status: 'succeeded', + content: { url: 'https://example.com/video.mp4' }, + }, + }), + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.video.getTask({ taskId: 'h3-123', model: 'MiniMax-H3' }); + + expect(result.status).toBe('succeeded'); + }); + + it('should poll a MiniMax-H3 task from running to succeeded', async () => { + let pollCount = 0; + server = createMockServer({ + routes: { + '/v2/video_generation': () => jsonResponse({ task_id: 'h3-sync' }), + '/v2/query/video_generation/h3-sync': () => { + pollCount++; + return jsonResponse({ + task: { + id: 'h3-sync', + model: 'MiniMax-H3', + status: pollCount === 1 ? 'running' : 'succeeded', + ...(pollCount === 1 + ? {} + : { content: { url: 'https://example.com/h3-sync.mp4' } }), + }, + }); + }, + }, + }); + + const sdk = new MiniMaxSDK({ + apiKey: 'test-key', + baseUrl: server.url, + }); + + const result = await sdk.video.generate({ + model: 'MiniMax-H3', + content: [{ type: 'text', text: 'Ocean waves' }], + pollInterval: 0, + }); + + expect(pollCount).toBe(2); + expect(result).toMatchObject({ + id: 'h3-sync', + status: 'succeeded', + content: { url: 'https://example.com/h3-sync.mp4' }, + }); + }); }); describe('VideoSDK.validateParams', () => { @@ -85,6 +216,26 @@ describe('VideoSDK.validateParams', () => { ).rejects.toThrow('MiniMax-Hailuo-2.3-Fast only supports I2V'); }); + it('keeps H3-only fields out of the MiniMax-Hailuo-2.3 request path', async () => { + await expect( + sdk.generate({ + model: 'MiniMax-Hailuo-2.3', + prompt: 'test', + duration: 5, + } as never), + ).rejects.toThrow('require model MiniMax-H3'); + + await expect( + sdk.generate({ + model: 'MiniMax-Hailuo-2.3', + content: [{ type: 'text', text: 'test' }], + resolution: '2K', + duration: 5, + ratio: '16:9', + } as never), + ).rejects.toThrow('content is only supported with model MiniMax-H3'); + }); + it('auto-selects SEF model when last_frame_image is provided', async () => { // Validation passes → tries network → fails with non-validation error await expect( diff --git a/test/utils/media.test.ts b/test/utils/media.test.ts new file mode 100644 index 00000000..c0a084f4 --- /dev/null +++ b/test/utils/media.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { resolveImageInput } from '../../src/utils/image'; +import { + dataUriDecodedSize, + resolveMediaInput, + VIDEO_V2_MEDIA_SIZE_LIMITS, +} from '../../src/utils/media'; + +describe('H3 media limits', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('calculates decoded Base64 data URI size', () => { + expect(dataUriDecodedSize('data:audio/wav;base64,QUJDRA==')).toBe(4); + expect(dataUriDecodedSize('https://example.com/audio.wav')).toBeUndefined(); + }); + + it('uses the H3-compatible MP3 data URI format', () => { + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-mp3-')); + tempDirs.push(dir); + const file = join(dir, 'reference.mp3'); + writeFileSync(file, 'mp3-data'); + + expect(resolveMediaInput(file, 'audio')).toStartWith('data:audio/mp3;base64,'); + expect(resolveMediaInput('data:audio/mpeg;base64,bXAz', 'audio')) + .toBe('data:audio/mp3;base64,bXAz'); + }); + + it('rejects oversized local reference audio before reading it', () => { + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-audio-')); + tempDirs.push(dir); + const file = join(dir, 'audio.wav'); + writeFileSync(file, ''); + truncateSync(file, VIDEO_V2_MEDIA_SIZE_LIMITS.audio + 1); + + expect(() => resolveMediaInput(file, 'audio')).toThrow('allows at most 15 MB'); + }); + + it('applies the H3 image limit only when requested', () => { + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-image-')); + tempDirs.push(dir); + const file = join(dir, 'image.png'); + writeFileSync(file, ''); + truncateSync(file, VIDEO_V2_MEDIA_SIZE_LIMITS.image + 1); + + expect(() => resolveImageInput(file, VIDEO_V2_MEDIA_SIZE_LIMITS.image)).toThrow('allows at most 30 MB'); + }); +}); diff --git a/test/video/v2.test.ts b/test/video/v2.test.ts new file mode 100644 index 00000000..3eb64553 --- /dev/null +++ b/test/video/v2.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'bun:test'; +import type { VideoV2ImageRole, VideoV2Request } from '../../src/types/api'; +import { + buildVideoV2Request, + validateVideoV2Request, +} from '../../src/video/v2'; +import { + VIDEO_V2_MAX_REQUEST_BODY_BYTES, + VIDEO_V2_MEDIA_SIZE_LIMITS, +} from '../../src/utils/media'; + +const prompt = 'A cinematic test scene'; + +function referenceImages(count: number) { + return Array.from({ length: count }, (_, index) => ({ + url: `https://example.com/reference-${index}.png`, + role: 'reference_image' as const, + })); +} + +function referenceMedia(count: number, extension: string) { + return Array.from( + { length: count }, + (_, index) => `https://example.com/reference-${index}.${extension}`, + ); +} + +function oversizedDataUri(kind: keyof typeof VIDEO_V2_MEDIA_SIZE_LIMITS, mime: string): string { + const decodedBytes = VIDEO_V2_MEDIA_SIZE_LIMITS[kind] + 1; + const base64Length = Math.ceil(decodedBytes / 3) * 4; + return `data:${mime};base64,${'A'.repeat(base64Length)}`; +} + +describe('Video Generation V2 request builder', () => { + it('maps first-frame, last-frame-only, and first-plus-last-frame inputs', () => { + const cases: Array<{ + images: Array<{ url: string; role: VideoV2ImageRole }>; + expectedRoles: VideoV2ImageRole[]; + }> = [ + { + images: [{ url: 'https://example.com/first.png', role: 'first_frame' }], + expectedRoles: ['first_frame'], + }, + { + images: [{ url: 'https://example.com/last.png', role: 'last_frame' }], + expectedRoles: ['last_frame'], + }, + { + images: [ + { url: 'https://example.com/first.png', role: 'first_frame' }, + { url: 'https://example.com/last.png', role: 'last_frame' }, + ], + expectedRoles: ['first_frame', 'last_frame'], + }, + ]; + + for (const testCase of cases) { + const request = buildVideoV2Request({ prompt, images: testCase.images }); + const roles = request.content + .filter(item => item.type === 'image_url') + .map(item => item.role); + + expect(roles).toEqual(testCase.expectedRoles); + expect(request.ratio).toBe('adaptive'); + } + }); + + it('rejects multiple images unless every image has an explicit role', () => { + expect(() => buildVideoV2Request({ + prompt, + images: [ + { url: 'https://example.com/one.png' }, + { url: 'https://example.com/two.png', role: 'reference_image' }, + ], + })).toThrow('Each image must specify a role'); + }); + + it('rejects invalid media roles at runtime', () => { + expect(() => buildVideoV2Request({ + prompt, + images: [{ + url: 'https://example.com/image.png', + role: 'thumbnail' as VideoV2ImageRole, + }], + })).toThrow('image role must be one of'); + + const invalidVideoRole = buildVideoV2Request({ prompt }); + invalidVideoRole.content.push({ + type: 'video_url', + video_url: { url: 'https://example.com/video.mp4' }, + role: 'reference_audio', + } as never); + expect(() => validateVideoV2Request(invalidVideoRole)).toThrow( + 'video role must be reference_video', + ); + }); + + it('rejects reference audio without a reference image or video', () => { + expect(() => buildVideoV2Request({ + prompt, + referenceAudios: ['https://example.com/audio.mp3'], + })).toThrow('reference audio requires at least one reference image or reference video'); + }); + + it('rejects frame inputs mixed with reference inputs', () => { + expect(() => buildVideoV2Request({ + prompt, + images: [{ url: 'https://example.com/first.png', role: 'first_frame' }], + referenceVideos: ['https://example.com/reference.mp4'], + })).toThrow('frame inputs and reference inputs cannot be used together'); + }); + + it('validates resolution, duration, and ratio boundaries', () => { + expect(() => buildVideoV2Request({ prompt, resolution: '1080P' })) + .toThrow('only supports 2K resolution'); + + for (const duration of [3, 16, 4.5]) { + expect(() => buildVideoV2Request({ prompt, duration })) + .toThrow('duration must be an integer from 4 to 15 seconds'); + } + + expect(() => buildVideoV2Request({ prompt, ratio: '2:1' })) + .toThrow('ratio must be one of'); + expect(() => buildVideoV2Request({ prompt, ratio: 'adaptive' })) + .toThrow('text-to-video requires a concrete ratio'); + }); + + it('accepts and rejects reference input count boundaries', () => { + expect(() => buildVideoV2Request({ prompt, images: referenceImages(9) })).not.toThrow(); + expect(() => buildVideoV2Request({ prompt, images: referenceImages(10) })) + .toThrow('accepts up to 9 reference images'); + + expect(() => buildVideoV2Request({ + prompt, + referenceVideos: referenceMedia(3, 'mp4'), + })).not.toThrow(); + expect(() => buildVideoV2Request({ + prompt, + referenceVideos: referenceMedia(4, 'mp4'), + })).toThrow('3 reference videos'); + + expect(() => buildVideoV2Request({ + prompt, + images: referenceImages(1), + referenceAudios: referenceMedia(3, 'mp3'), + })).not.toThrow(); + expect(() => buildVideoV2Request({ + prompt, + images: referenceImages(1), + referenceAudios: referenceMedia(4, 'mp3'), + })).toThrow('3 reference audios'); + }); + + it('rejects oversized Base64 image, video, and audio inputs', () => { + expect(() => buildVideoV2Request({ + prompt, + images: [{ url: oversizedDataUri('image', 'image/png'), role: 'reference_image' }], + })).toThrow('image input is 30.0 MB; the maximum is 30 MB'); + + expect(() => buildVideoV2Request({ + prompt, + referenceVideos: [oversizedDataUri('video', 'video/mp4')], + })).toThrow('video input is 50.0 MB; the maximum is 50 MB'); + + expect(() => buildVideoV2Request({ + prompt, + images: referenceImages(1), + referenceAudios: [oversizedDataUri('audio', 'audio/mpeg')], + })).toThrow('audio input is 15.0 MB; the maximum is 15 MB'); + }); + + it('rejects a JSON request body larger than 64 MB', () => { + const oversizedUrl = `https://example.com/${'a'.repeat( + Math.floor(VIDEO_V2_MAX_REQUEST_BODY_BYTES / 9) + 1, + )}`; + + expect(() => buildVideoV2Request({ + prompt, + images: Array.from({ length: 9 }, () => ({ + url: oversizedUrl, + role: 'reference_image' as const, + })), + })).toThrow('request body is 64.0 MB; the maximum is 64 MB'); + }); +}); + +describe('Video Generation V2 raw request validation', () => { + it('requires non-empty text content', () => { + const request = { + model: 'MiniMax-H3', + content: [{ type: 'text', text: ' ' }], + resolution: '2K', + duration: 5, + ratio: '16:9', + } satisfies VideoV2Request; + + expect(() => validateVideoV2Request(request)).toThrow('requires a non-empty text content item'); + }); +});