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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/commands/video/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { resolveMediaInput, VIDEO_V2_MEDIA_SIZE_LIMITS } from '../../utils/media
import { promptOrFail } from '../../utils/prompt';
import {
buildVideoV2Request,
getVideoV2FailureReason,
isVideoV2Model,
isVideoV2Request,
VIDEO_V2_MODEL,
Expand All @@ -47,7 +48,7 @@ export default defineCommand({
{ flag: '--last-frame <path-or-url>', description: 'Optional ending image. Legacy SEF also requires --image; MiniMax-H3 supports a last frame alone.' },
{ flag: '--subject-image <path-or-url>', description: 'Subject reference image for character consistency (local path or URL). Switches to S2V-01 model.' },
{ flag: '--reference-image <path-or-url>', description: 'H3 reference image (repeatable).', type: 'array' },
{ flag: '--reference-video <path-or-url>', description: 'H3 reference video (repeatable; local MP4, URL, data URI, or mm_file:// ID).', type: 'array' },
{ flag: '--reference-video <path-or-url>', description: 'H3 reference video (repeatable; local MP4/MOV, URL, data URI, or mm_file:// ID).', type: 'array' },
{ flag: '--reference-audio <path-or-url>', description: 'H3 reference audio (repeatable; requires a reference image or video).', type: 'array' },
{ flag: '--duration <seconds>', description: 'Output duration. H3 supports integer values from 4 to 15 (default: 5).', type: 'number' },
{ flag: '--ratio <ratio>', description: 'H3 aspect ratio: adaptive, 21:9, 16:9, 4:3, 1:1, 3:4, or 9:16.' },
Expand Down Expand Up @@ -260,6 +261,7 @@ export default defineCommand({
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,
getFailureReason: (d) => getVideoV2FailureReason((d as VideoV2TaskResponse).task),
});
status = result.task.status;
downloadUrl = result.task.content?.url;
Expand Down
22 changes: 20 additions & 2 deletions src/errors/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface ApiErrorBody {
message?: string;
type?: string;
code?: number;
http_code?: string | number;
};
}

Expand All @@ -35,6 +36,7 @@ export function mapApiError(status: number, body: ApiErrorBody, url?: string): C
`HTTP ${status}`;

const apiCode = body.base_resp?.status_code || body.error?.code;
const errorType = body.error?.type;

if (status === 401 || status === 403) {
return new CLIError(
Expand All @@ -52,6 +54,14 @@ export function mapApiError(status: number, body: ApiErrorBody, url?: string): C
);
}

if (status === 402 || errorType === 'insufficient_balance_error') {
return new CLIError(
`Quota or balance exhausted. ${apiMsg}`,
ExitCode.QUOTA,
'Check your MiniMax account balance and billing settings.',
);
}

if (status === 408 || status === 504) {
return new CLIError(
`Request timed out (HTTP ${status}).`,
Expand All @@ -60,9 +70,17 @@ export function mapApiError(status: number, body: ApiErrorBody, url?: string): C
);
}

const isV2ContentFilter =
status === 422 &&
errorType === 'unprocessable_entity_error' &&
(/sensitive content/i.test(apiMsg) || /(?:^|\D)1026(?:\D|$)/.test(apiMsg));

// MiniMax content sensitivity filter
if (apiCode === 1002 || apiCode === 1039) {
const filterType = body.base_resp?.status_msg || 'content sensitivity';
if (apiCode === 1002 || apiCode === 1039 || isV2ContentFilter) {
const filterType =
body.base_resp?.status_msg ||
body.error?.message ||
'content sensitivity';
return new CLIError(
`Input content flagged by sensitivity filter (${filterType}).`,
ExitCode.CONTENT_FILTER,
Expand Down
9 changes: 6 additions & 3 deletions src/polling/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface PollOptions {
isComplete: (data: unknown) => boolean;
isFailed: (data: unknown) => boolean;
getStatus?: (data: unknown) => string;
getFailureReason?: (data: unknown) => string | undefined;
}

export async function poll<T>(config: Config, opts: PollOptions): Promise<T> {
Expand All @@ -36,9 +37,11 @@ export async function poll<T>(config: Config, opts: PollOptions): Promise<T> {
spinner.stop('Failed.');
// Include API status context to help users diagnose failures
const status = opts.getStatus ? opts.getStatus(data) : 'failed';
const extra = (data as Record<string, unknown>)?.base_resp
? ` (${(data as { base_resp: { status_code?: number; status_msg?: string } }).base_resp.status_msg})`
: '';
const baseResponseMessage = (
data as { base_resp?: { status_msg?: string } }
).base_resp?.status_msg;
const failureReason = opts.getFailureReason?.(data) ?? baseResponseMessage;
const extra = failureReason ? ` (${failureReason})` : '';
throw new CLIError(
`Task ${status}.${extra}`,
ExitCode.GENERAL,
Expand Down
18 changes: 18 additions & 0 deletions src/sdk/video/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { SDKError } from "../../errors/base";
import { ExitCode } from "../../errors/codes";
import {
buildVideoV2Request,
getVideoV2FailureReason,
isVideoV2Model,
isVideoV2Request,
validateVideoV2Request,
Expand Down Expand Up @@ -77,6 +78,7 @@ export class VideoSDK extends Client {
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,
getFailureReason: (d) => getVideoV2FailureReason((d as VideoV2TaskResponse).task),
});
return result.task;
}
Expand Down Expand Up @@ -130,6 +132,17 @@ export class VideoSDK extends Client {
if (params.model && !isVideoV2Model(params.model as string)) {
throw new VideoV2InputError('content is only supported with model MiniMax-H3');
}
const conflictingFields = [
'prompt',
'first_frame_image',
'last_frame_image',
'subject_reference',
].filter(field => params[field] !== undefined);
if (conflictingFields.length > 0) {
throw new VideoV2InputError(
`content cannot be combined with legacy video fields: ${conflictingFields.join(', ')}`,
);
}
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'),
Expand All @@ -152,6 +165,11 @@ export class VideoSDK extends Client {
if (!prompt) {
throw new VideoV2InputError('prompt or content is required');
}
if (params.subject_reference !== undefined) {
throw new VideoV2InputError(
'subject_reference is only supported by legacy video models. Use content with role reference_image for MiniMax-H3.',
);
}
return buildVideoV2Request({
prompt,
images: [
Expand Down
1 change: 1 addition & 0 deletions src/utils/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ExitCode } from '../errors/codes';
const MEDIA_MIME_TYPES = {
video: {
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
},
audio: {
'.mp3': 'audio/mp3',
Expand Down
9 changes: 9 additions & 0 deletions src/video/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
VideoV2ImageRole,
VideoV2Ratio,
VideoV2Request,
VideoV2Task,
} from '../types/api';
import {
dataUriDecodedSize,
Expand Down Expand Up @@ -61,6 +62,14 @@ export function isVideoV2Request(request: VideoRequest | VideoV2Request): reques
return isVideoV2Model(request.model);
}

export function getVideoV2FailureReason(task: VideoV2Task): string | undefined {
const code = task.error?.code?.trim();
const message = task.error?.message?.trim();

if (code && message) return `${code}: ${message}`;
return message || code;
}

export function buildVideoV2Request(options: BuildVideoV2RequestOptions): VideoV2Request {
const images = options.images ?? [];
if (images.length > 1 && images.some(image => !image.role)) {
Expand Down
43 changes: 43 additions & 0 deletions test/commands/video/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,49 @@ describe('video generate command', () => {
}
});

it('surfaces H3 task failure details from the V2 query response', async () => {
const server = createMockServer({
routes: {
'/v2/video_generation': () => new Response(JSON.stringify({ task_id: 'h3-failed' }), {
headers: { 'Content-Type': 'application/json' },
}),
'/v2/query/video_generation/h3-failed': () => new Response(JSON.stringify({
task: {
id: 'h3-failed',
model: 'MiniMax-H3',
status: 'failed',
error: {
code: 'H3_FAILED',
message: 'Reference video could not be decoded',
},
},
}), {
headers: { 'Content-Type': 'application/json' },
}),
},
});

try {
await expect(
generateCommand.execute({
...h3DryRunConfig,
baseUrl: server.url,
quiet: true,
dryRun: false,
}, {
...baseFlags,
prompt: 'Ocean waves',
model: 'MiniMax-H3',
pollInterval: 0,
quiet: true,
dryRun: false,
}),
).rejects.toThrow('H3_FAILED: Reference video could not be decoded');
} finally {
server.close();
}
});

it('keeps H3-only parameters out of the MiniMax-Hailuo-2.3 path', async () => {
const config = {
apiKey: 'test-key',
Expand Down
39 changes: 39 additions & 0 deletions test/errors/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ describe('mapApiError', () => {
expect(err.exitCode).toBe(ExitCode.QUOTA);
});

it('maps Video V2 insufficient balance errors to QUOTA', () => {
const err = mapApiError(402, {
error: {
type: 'insufficient_balance_error',
message: 'insufficient balance (1008)',
http_code: '402',
},
});

expect(err.exitCode).toBe(ExitCode.QUOTA);
expect(err.message).toContain('insufficient balance');
expect(err.hint).toContain('account balance');
});

it('maps 408 to TIMEOUT exit code', () => {
const err = mapApiError(408, {});
expect(err.exitCode).toBe(ExitCode.TIMEOUT);
Expand All @@ -29,6 +43,31 @@ describe('mapApiError', () => {
expect(err.exitCode).toBe(ExitCode.CONTENT_FILTER);
});

it('maps Video V2 sensitive-content errors to CONTENT_FILTER', () => {
const err = mapApiError(422, {
error: {
type: 'unprocessable_entity_error',
message: '视频描述包含敏感内容 (1026)',
http_code: '422',
},
});

expect(err.exitCode).toBe(ExitCode.CONTENT_FILTER);
expect(err.message).toContain('视频描述包含敏感内容');
});

it('keeps unrelated Video V2 validation errors as GENERAL', () => {
const err = mapApiError(422, {
error: {
type: 'unprocessable_entity_error',
message: 'invalid media dimensions',
http_code: '422',
},
});

expect(err.exitCode).toBe(ExitCode.GENERAL);
});

it('maps MiniMax quota code 1028', () => {
const err = mapApiError(400, { base_resp: { status_code: 1028, status_msg: 'quota exhausted' } });
expect(err.exitCode).toBe(ExitCode.QUOTA);
Expand Down
31 changes: 31 additions & 0 deletions test/polling/poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,37 @@ describe('poll', () => {
).rejects.toThrow('Task error');
});

it('includes a structured failure reason when provided', async () => {
setFetch(mock(() => Promise.resolve(jsonRes({
task: {
status: 'failed',
error: { code: 'H3_FAILED', message: 'Reference video could not be decoded' },
},
}))));

const { poll } = await import('../../src/polling/poll');
await expect(
poll(baseConfig, {
url: 'https://api.mmx.io/poll',
intervalSec: 0.01,
timeoutSec: 5,
isComplete: () => false,
isFailed: (data) => (
data as { task: { status: string } }
).task.status === 'failed',
getStatus: (data) => (
data as { task: { status: string } }
).task.status,
getFailureReason: (data) => {
const error = (
data as { task: { error?: { code?: string; message?: string } } }
).task.error;
return [error?.code, error?.message].filter(Boolean).join(': ');
},
}),
).rejects.toThrow('H3_FAILED: Reference video could not be decoded');
});

it('throws on timeout', async () => {
setFetch(mock(() => Promise.resolve(jsonRes({ status: 'Processing' }))));

Expand Down
61 changes: 59 additions & 2 deletions test/sdk/video.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server';
import { MiniMaxSDK } from '../../src/sdk';
import { VideoSDK } from '../../src/sdk/video';
import { VideoSDK, type VideoAsyncGenerateRequest } from '../../src/sdk/video';

describe('MiniMaxSDK.video', () => {
let server: MockServer;
Expand Down Expand Up @@ -184,13 +184,70 @@ describe('MiniMaxSDK.video', () => {
content: { url: 'https://example.com/h3-sync.mp4' },
});
});

it('surfaces MiniMax-H3 task failure details while polling', async () => {
server = createMockServer({
routes: {
'/v2/video_generation': () => jsonResponse({ task_id: 'h3-failed' }),
'/v2/query/video_generation/h3-failed': () => jsonResponse({
task: {
id: 'h3-failed',
model: 'MiniMax-H3',
status: 'failed',
error: {
code: 'H3_FAILED',
message: 'Reference video could not be decoded',
},
},
}),
},
});

const sdk = new MiniMaxSDK({
apiKey: 'test-key',
baseUrl: server.url,
});

await expect(
sdk.video.generate({
model: 'MiniMax-H3',
content: [{ type: 'text', text: 'Ocean waves' }],
pollInterval: 0,
}),
).rejects.toThrow('H3_FAILED: Reference video could not be decoded');
});
});

describe('VideoSDK.validateParams', () => {
const sdk = new VideoSDK({ apiKey: 'sk-test', region: 'global' });

it('throws when prompt is missing', async () => {
await expect(sdk.generate({} as any)).rejects.toThrow('prompt is required');
await expect(
sdk.generate({} as VideoAsyncGenerateRequest),
).rejects.toThrow('prompt is required');
});

it('rejects legacy subject_reference for MiniMax-H3 instead of dropping it', async () => {
await expect(
sdk.generate({
model: 'MiniMax-H3',
prompt: 'Keep the same character',
subject_reference: [{
type: 'character',
image: ['https://example.com/character.png'],
}],
}),
).rejects.toThrow('Use content with role reference_image');
});

it('rejects raw H3 content mixed with legacy request fields', async () => {
await expect(
sdk.generate({
model: 'MiniMax-H3',
prompt: 'Legacy prompt',
content: [{ type: 'text', text: 'H3 prompt' }],
} as never),
).rejects.toThrow('content cannot be combined with legacy video fields: prompt');
});

it('throws when last_frame_image is provided without first_frame_image', async () => {
Expand Down
Loading
Loading