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
3 changes: 3 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface Command {
usage?: string;
options?: OptionDef[];
examples?: string[];
apiDocs?: string;
execute(config: Config, flags: GlobalFlags): Promise<void>;
}

Expand All @@ -23,6 +24,7 @@ export interface CommandSpec {
usage?: string;
options?: OptionDef[];
examples?: string[];
apiDocs?: string;
run(config: Config, flags: GlobalFlags): Promise<void>;
}

Expand All @@ -33,6 +35,7 @@ export function defineCommand(spec: CommandSpec): Command {
usage: spec.usage,
options: spec.options,
examples: spec.examples,
apiDocs: spec.apiDocs,
execute: spec.run,
};
}
Expand Down
73 changes: 64 additions & 9 deletions src/commands/video/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ import { promptText, failIfMissing } from '../../utils/prompt';

export default defineCommand({
name: 'video generate',
description: 'Generate a video (Hailuo-2.3 / 2.3-Fast)',
description: 'Generate a video (T2V: Hailuo-2.3 / 2.3-Fast / Hailuo-02 | I2V: I2V-01 / I2V-01-Director / I2V-01-live | S2V: S2V-01)',
apiDocs: '/docs/api-reference/video-generation',
usage: 'mmx video generate --prompt <text> [flags]',
options: [
{ flag: '--model <model>', description: 'Model ID (default: MiniMax-Hailuo-2.3)' },
{ flag: '--model <model>', description: 'Model ID (default: MiniMax-Hailuo-2.3). Auto-switched to Hailuo-02 with --last-frame, or S2V-01 with --subject-image.' },
{ flag: '--prompt <text>', description: 'Video description', required: true },
{ flag: '--first-frame <path-or-url>', description: 'First frame image' },
{ flag: '--first-frame <path-or-url>', description: 'First frame image (local path or URL). Auto base64-encoded for local files.' },
{ flag: '--last-frame <path-or-url>', description: 'Last frame image (local path or URL). Enables SEF (start-end frame) interpolation mode with Hailuo-02 model. Requires --first-frame.' },
{ flag: '--subject-image <path-or-url>', description: 'Subject reference image for character consistency (local path or URL). Switches to S2V-01 model.' },
{ flag: '--callback-url <url>', description: 'Webhook URL for completion notification' },
{ flag: '--download <path>', description: 'Save video to file on completion' },
{ flag: '--no-wait', description: 'Return task ID immediately without waiting' },
Expand All @@ -38,6 +41,10 @@ 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',
'# SEF: first + last frame interpolation (uses Hailuo-02 model)',
'mmx video generate --prompt "Walk forward" --first-frame 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',
],
async run(config: Config, flags: GlobalFlags) {
let prompt = flags.prompt as string | undefined;
Expand All @@ -55,14 +62,31 @@ export default defineCommand({
}
}

const model = (flags.model as string) || 'MiniMax-Hailuo-2.3';
// 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 <text> --first-frame <path> --last-frame <path>',
);
}

// Determine model: explicit --model overrides auto-switch
const explicitModel = flags.model as string | undefined;
let model = explicitModel || 'MiniMax-Hailuo-2.3';
if (!explicitModel && flags.lastFrame) {
model = 'MiniMax-Hailuo-02';
} else if (!explicitModel && flags.subjectImage) {
model = 'S2V-01';
}
const format = detectOutputFormat(config.output);

const body: VideoRequest = {
model,
prompt,
};

// First frame (I2V)
if (flags.firstFrame) {
const framePath = flags.firstFrame as string;
if (framePath.startsWith('http')) {
Expand All @@ -75,6 +99,41 @@ export default defineCommand({
}
}

// Last frame (SEF mode)
if (flags.lastFrame) {
if (!flags.firstFrame) {
throw new CLIError(
'--last-frame requires --first-frame (SEF mode).',
ExitCode.USAGE,
'mmx video generate --prompt <text> --first-frame <path> --last-frame <path>',
);
}
const framePath = flags.lastFrame as string;
if (framePath.startsWith('http')) {
body.last_frame_image = framePath;
} else {
const imgData = readFileSync(framePath);
const ext = extname(framePath).toLowerCase();
const mime = MIME_TYPES[ext] || 'image/jpeg';
body.last_frame_image = `data:${mime};base64,${imgData.toString('base64')}`;
}
}

// Subject reference (S2V mode)
if (flags.subjectImage) {
const imgPath = flags.subjectImage as string;
let imageData: string;
if (imgPath.startsWith('http')) {
imageData = imgPath;
} else {
const imgData = readFileSync(imgPath);
const ext = extname(imgPath).toLowerCase();
const mime = MIME_TYPES[ext] || 'image/jpeg';
imageData = `data:${mime};base64,${imgData.toString('base64')}`;
}
body.subject_reference = [{ type: 'character', image: [imageData] }];
}

if (flags.callbackUrl) {
body.callback_url = flags.callbackUrl as string;
}
Expand All @@ -93,15 +152,12 @@ export default defineCommand({

const taskId = response.task_id;

if (!config.quiet && !flags.noWait && !config.async) {
process.stderr.write(`[Model: ${model}]\n`);
} else if (!config.quiet) {
if (!config.quiet) {
process.stderr.write(`[Model: ${model}]\n`);
}

// --no-wait or --async: return task ID immediately
if (flags.noWait || config.async) {
// Always pure JSON — Agent/CI mode needs predictable stdout
process.stdout.write(JSON.stringify({ taskId }));
process.stdout.write('\n');
return;
Expand Down Expand Up @@ -169,7 +225,6 @@ export default defineCommand({

await downloadFile(downloadUrl, destPath, { quiet: config.quiet });

// Pure local path output (stdout stays clean for piping)
process.stdout.write(destPath);
process.stdout.write('\n');
},
Expand Down
5 changes: 5 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ export interface VideoRequest {
model: string;
prompt: string;
first_frame_image?: string;
last_frame_image?: string;
callback_url?: string;
subject_reference?: Array<{
type: string;
image: string[];
}>;
}

export interface VideoResponse {
Expand Down
Loading