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
51 changes: 16 additions & 35 deletions src/commands/image/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@ import { ExitCode } from '../../errors/codes';
import { requestJson } from '../../client/http';
import { imageEndpoint } from '../../client/endpoints';
import { downloadFile } from '../../files/download';
import { formatOutput, detectOutputFormat } from '../../output/formatter';
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { ImageRequest, ImageResponse } from '../../types/api';
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join, resolve, extname } from 'path';
import { isInteractive } from '../../utils/env';
import { promptText, failIfMissing } from '../../utils/prompt';

const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp',
};
import { mkdirSync, existsSync, writeFileSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { localFileToDataUri } from '../../utils/image';
import { promptOrFail } from '../../utils/prompt';

export default defineCommand({
name: 'image generate',
Expand Down Expand Up @@ -56,20 +51,14 @@ export default defineCommand({
async run(config: Config, flags: GlobalFlags) {
let prompt = (flags.prompt ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined;

if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: 'Enter your image prompt:',
});
if (!hint) {
process.stderr.write('Image generation cancelled.\n');
process.exit(1);
}
prompt = hint;
} else {
failIfMissing('prompt', 'mmx image generate --prompt <text>');
}
}
prompt = await promptOrFail({
value: prompt,
message: 'Enter your image prompt:',
cancelMessage: 'Image generation cancelled.',
flagName: 'prompt',
usageHint: 'mmx image generate --prompt <text>',
nonInteractive: config.nonInteractive,
});

// Validate width/height
const width = flags.width as number | undefined;
Expand Down Expand Up @@ -132,24 +121,16 @@ export default defineCommand({
if (params.image.startsWith('http')) {
ref.image_url = params.image;
} else {
const imgPath = resolve(params.image);
const imgData = readFileSync(imgPath);
const ext = extname(imgPath).toLowerCase();
const mime = MIME_TYPES[ext] || 'image/jpeg';
ref.image_file = `data:${mime};base64,${imgData.toString('base64')}`;
ref.image_file = localFileToDataUri(resolve(params.image));
}
}

body.subject_reference = [ref];
}

const format = detectOutputFormat(config.output);

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
}
if (dryRun(config, body)) return;

const format = detectOutputFormat(config.output);
const url = imageEndpoint(config.baseUrl);
const response = await requestJson<ImageResponse>(config, {
url,
Expand Down
9 changes: 3 additions & 6 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { request, requestJson } from '../../client/http';
import { musicEndpoint } from '../../client/endpoints';
import { formatOutput, detectOutputFormat } from '../../output/formatter';
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
import { saveAudioOutput } from '../../output/audio';
import { readTextFromPathOrStdin } from '../../utils/fs';
import type { Config } from '../../config/schema';
Expand Down Expand Up @@ -122,7 +122,6 @@ export default defineCommand({
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
const ext = (flags.format as string) || 'mp3';
const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`;
const format = detectOutputFormat(config.output);

const model = (flags.model as string) || musicGenerateModel(config);
const VALID_MODELS = ['music-2.6', 'music-2.6-free', 'music-2.5+', 'music-2.5'];
Expand Down Expand Up @@ -165,11 +164,9 @@ export default defineCommand({

if (flags.aigcWatermark) body.aigc_watermark = true;

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
}
if (dryRun(config, body)) return;

const format = detectOutputFormat(config.output);
const url = musicEndpoint(config.baseUrl);

if (flags.stream) {
Expand Down
9 changes: 3 additions & 6 deletions src/commands/speech/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ExitCode } from '../../errors/codes';
import { request, requestJson } from '../../client/http';
import { speechEndpoint } from '../../client/endpoints';
import { parseSSE } from '../../client/stream';
import { detectOutputFormat, formatOutput } from '../../output/formatter';
import { detectOutputFormat, formatOutput, dryRun } from '../../output/formatter';
import { saveAudioOutput } from '../../output/audio';
import { writeFileSync } from 'fs';
import { readTextFromPathOrStdin } from '../../utils/fs';
Expand Down Expand Up @@ -65,7 +65,6 @@ export default defineCommand({
const ext = (flags.format as string) || 'mp3';
const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`;
const outFormat = 'hex';
const format = detectOutputFormat(config.output);

const body: SpeechRequest = {
model,
Expand Down Expand Up @@ -96,11 +95,9 @@ export default defineCommand({
});
}

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
}
if (dryRun(config, body)) return;

const format = detectOutputFormat(config.output);
const url = speechEndpoint(config.baseUrl);

if (flags.stream) {
Expand Down
72 changes: 16 additions & 56 deletions src/commands/video/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,12 @@ import { requestJson } from '../../client/http';
import { videoGenerateEndpoint, videoTaskEndpoint, fileRetrieveEndpoint } from '../../client/endpoints';
import { poll } from '../../polling/poll';
import { downloadFile, formatBytes } from '../../files/download';
import { formatOutput, detectOutputFormat } from '../../output/formatter';
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 { readFileSync } from 'fs';
import { extname } from 'path';

const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp',
};
import { isInteractive } from '../../utils/env';
import { promptText, failIfMissing } from '../../utils/prompt';
import { resolveImageInput } from '../../utils/image';
import { promptOrFail } from '../../utils/prompt';

export default defineCommand({
name: 'video generate',
Expand Down Expand Up @@ -49,18 +42,14 @@ export default defineCommand({
async run(config: Config, flags: GlobalFlags) {
let prompt = flags.prompt as string | undefined;

if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: 'Enter your video prompt:' });
if (!hint) {
process.stderr.write('Video generation cancelled.\n');
process.exit(1);
}
prompt = hint;
} else {
failIfMissing('prompt', 'mmx video generate --prompt <text>');
}
}
prompt = await promptOrFail({
value: prompt,
message: 'Enter your video prompt:',
cancelMessage: 'Video generation cancelled.',
flagName: 'prompt',
usageHint: 'mmx video generate --prompt <text>',
nonInteractive: config.nonInteractive,
});

// Validate mutually exclusive mode flags
if (flags.lastFrame && flags.subjectImage) {
Expand Down Expand Up @@ -92,7 +81,6 @@ export default defineCommand({
} else {
model = config.defaultVideoModel || 'MiniMax-Hailuo-2.3';
}
const format = detectOutputFormat(config.output);

const body: VideoRequest = {
model,
Expand All @@ -101,15 +89,7 @@ export default defineCommand({

// First frame (I2V)
if (flags.firstFrame) {
const framePath = flags.firstFrame as string;
if (framePath.startsWith('http')) {
body.first_frame_image = framePath;
} else {
const imgData = readFileSync(framePath);
const ext = extname(framePath).toLowerCase();
const mime = MIME_TYPES[ext] || 'image/jpeg';
body.first_frame_image = `data:${mime};base64,${imgData.toString('base64')}`;
}
body.first_frame_image = resolveImageInput(flags.firstFrame as string);
}

// Last frame (SEF mode)
Expand All @@ -121,41 +101,21 @@ export default defineCommand({
'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')}`;
}
body.last_frame_image = resolveImageInput(flags.lastFrame as string);
}

// 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] }];
body.subject_reference = [{ type: 'character', image: [resolveImageInput(flags.subjectImage as string)] }];
}

if (flags.callbackUrl) {
body.callback_url = flags.callbackUrl as string;
}

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
}
if (dryRun(config, body)) return;

const format = detectOutputFormat(config.output);
const url = videoGenerateEndpoint(config.baseUrl);
const response = await requestJson<VideoResponse>(config, {
url,
Expand Down
50 changes: 4 additions & 46 deletions src/commands/vision/describe.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,19 @@
import { defineCommand } from '../../command';
import { requestJson } from '../../client/http';
import { vlmEndpoint } from '../../client/endpoints';
import { formatOutput, detectOutputFormat } from '../../output/formatter';
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import { readFileSync, existsSync } from 'fs';
import { extname } from 'path';
import { isInteractive } from '../../utils/env';
import { promptText } from '../../utils/prompt';
import { toDataUri } from '../../utils/image';

interface VlmResponse {
content: string;
}

const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
};

const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB limit

export async function toDataUri(image: string): Promise<string> {
if (image.startsWith('data:')) return image;

if (image.startsWith('http://') || image.startsWith('https://')) {
const res = await fetch(image);
if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL);
const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {
throw new CLIError(
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
ExitCode.USAGE,
);
}
const b64 = Buffer.from(buf).toString('base64');
return `data:${mime};base64,${b64}`;
}

// Local file
if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE);
const ext = extname(image).toLowerCase();
const mime = MIME_TYPES[ext];
if (!mime) throw new CLIError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, ExitCode.USAGE);
const buf = readFileSync(image);
return `data:${mime};base64,${buf.toString('base64')}`;
}

export default defineCommand({
name: 'vision describe',
description: 'Describe an image using MiniMax VLM',
Expand Down Expand Up @@ -101,13 +63,9 @@ export default defineCommand({
);
}

const format = detectOutputFormat(config.output);

if (config.dryRun) {
process.stdout.write(formatOutput({ request: { prompt, image, fileId } }, format) + '\n');
return;
}
if (dryRun(config, { prompt, image, fileId })) return;

const format = detectOutputFormat(config.output);
const url = vlmEndpoint(config.baseUrl);
const body: Record<string, unknown> = { prompt };

Expand Down
6 changes: 6 additions & 0 deletions src/output/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
return formatText(data);
}
}

export function dryRun(config: { dryRun?: boolean; output?: string }, body: unknown): boolean {
if (!config.dryRun) return false;
console.log(formatOutput({ request: body }, detectOutputFormat(config.output)));
return true;
}
18 changes: 18 additions & 0 deletions src/sdk/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { loadConfig } from "../config/loader";
import { Config } from "../config/schema";
import { request as requestClient, requestJson as requestJsonClient, RequestOpts } from "../client/http";
import { parseSSE } from "../client/stream";
import { SDKError } from "../errors/base";
import { ExitCode } from "../errors/codes";
import { MiniMaxSDKOptions } from "./types";

export class Client {
Expand Down Expand Up @@ -30,4 +33,19 @@ export class Client {
protected requestJson<T>(opts: RequestOpts): Promise<T> {
return requestJsonClient<T>(this.config, opts);
}

protected async *streamSSE<T>(res: Response): AsyncGenerator<T> {
for await (const event of parseSSE(res)) {
if (event.data === '[DONE]') break;
if (!event.data) continue;
try {
yield JSON.parse(event.data) as T;
} catch (err) {
throw new SDKError(
`Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}`,
ExitCode.GENERAL,
);
}
}
}
}
2 changes: 1 addition & 1 deletion src/sdk/music/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MusicRequest, MusicResponse } from "../../types/api";
import { ModelPartial } from "../types";
import { SDKError } from "../../errors/base";
import { ExitCode } from "../../errors/codes";
import { toMerged } from "es-toolkit";
import { toMerged } from "es-toolkit/object";
import { musicGenerateModel } from "../../commands/music/models";

export interface MusicGenerateRequest extends MusicRequest {
Expand Down
Loading
Loading