Skip to content

Commit 3b59db2

Browse files
committed
refactor: deduplicate MIME types, image encoding, SSE parsing, and dry-run boilerplate
Extract shared utilities to eliminate code duplication across commands and SDK: - src/utils/image.ts: IMAGE_MIME_TYPES, localFileToDataUri, resolveImageInput, toDataUri - src/utils/prompt.ts: promptOrFail for interactive input with non-interactive fallback - src/sdk/client.ts: streamSSE<T> generic method for SSE stream consumption - src/output/formatter.ts: dryRun helper for consistent dry-run output Fix SDK→Command architectural inversion (sdk/vision imported from commands/vision). Fix SSE stream: empty data events now skip (continue) instead of terminating (break). Standardize toMerged import to es-toolkit/object across all SDK modules.
1 parent e24422b commit 3b59db2

15 files changed

Lines changed: 181 additions & 181 deletions

File tree

src/commands/image/generate.ts

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,14 @@ import { ExitCode } from '../../errors/codes';
44
import { requestJson } from '../../client/http';
55
import { imageEndpoint } from '../../client/endpoints';
66
import { downloadFile } from '../../files/download';
7-
import { formatOutput, detectOutputFormat } from '../../output/formatter';
7+
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
88
import type { Config } from '../../config/schema';
99
import type { GlobalFlags } from '../../types/flags';
1010
import type { ImageRequest, ImageResponse } from '../../types/api';
11-
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
12-
import { dirname, join, resolve, extname } from 'path';
13-
import { isInteractive } from '../../utils/env';
14-
import { promptText, failIfMissing } from '../../utils/prompt';
15-
16-
const MIME_TYPES: Record<string, string> = {
17-
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
18-
'.png': 'image/png', '.webp': 'image/webp',
19-
};
11+
import { mkdirSync, existsSync, writeFileSync } from 'fs';
12+
import { dirname, join, resolve } from 'path';
13+
import { localFileToDataUri } from '../../utils/image';
14+
import { promptOrFail } from '../../utils/prompt';
2015

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

59-
if (!prompt) {
60-
if (isInteractive({ nonInteractive: config.nonInteractive })) {
61-
const hint = await promptText({
62-
message: 'Enter your image prompt:',
63-
});
64-
if (!hint) {
65-
process.stderr.write('Image generation cancelled.\n');
66-
process.exit(1);
67-
}
68-
prompt = hint;
69-
} else {
70-
failIfMissing('prompt', 'mmx image generate --prompt <text>');
71-
}
72-
}
54+
prompt = await promptOrFail({
55+
value: prompt,
56+
message: 'Enter your image prompt:',
57+
cancelMessage: 'Image generation cancelled.',
58+
flagName: 'prompt',
59+
usageHint: 'mmx image generate --prompt <text>',
60+
nonInteractive: config.nonInteractive,
61+
});
7362

7463
// Validate width/height
7564
const width = flags.width as number | undefined;
@@ -132,24 +121,16 @@ export default defineCommand({
132121
if (params.image.startsWith('http')) {
133122
ref.image_url = params.image;
134123
} else {
135-
const imgPath = resolve(params.image);
136-
const imgData = readFileSync(imgPath);
137-
const ext = extname(imgPath).toLowerCase();
138-
const mime = MIME_TYPES[ext] || 'image/jpeg';
139-
ref.image_file = `data:${mime};base64,${imgData.toString('base64')}`;
124+
ref.image_file = localFileToDataUri(resolve(params.image));
140125
}
141126
}
142127

143128
body.subject_reference = [ref];
144129
}
145130

146-
const format = detectOutputFormat(config.output);
147-
148-
if (config.dryRun) {
149-
console.log(formatOutput({ request: body }, format));
150-
return;
151-
}
131+
if (dryRun(config, body)) return;
152132

133+
const format = detectOutputFormat(config.output);
153134
const url = imageEndpoint(config.baseUrl);
154135
const response = await requestJson<ImageResponse>(config, {
155136
url,

src/commands/music/generate.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CLIError } from '../../errors/base';
33
import { ExitCode } from '../../errors/codes';
44
import { request, requestJson } from '../../client/http';
55
import { musicEndpoint } from '../../client/endpoints';
6-
import { formatOutput, detectOutputFormat } from '../../output/formatter';
6+
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
77
import { saveAudioOutput } from '../../output/audio';
88
import { readTextFromPathOrStdin } from '../../utils/fs';
99
import type { Config } from '../../config/schema';
@@ -122,7 +122,6 @@ export default defineCommand({
122122
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
123123
const ext = (flags.format as string) || 'mp3';
124124
const outPath = (flags.out as string | undefined) ?? `music_${ts}.${ext}`;
125-
const format = detectOutputFormat(config.output);
126125

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

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

168-
if (config.dryRun) {
169-
console.log(formatOutput({ request: body }, format));
170-
return;
171-
}
167+
if (dryRun(config, body)) return;
172168

169+
const format = detectOutputFormat(config.output);
173170
const url = musicEndpoint(config.baseUrl);
174171

175172
if (flags.stream) {

src/commands/speech/synthesize.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ExitCode } from '../../errors/codes';
44
import { request, requestJson } from '../../client/http';
55
import { speechEndpoint } from '../../client/endpoints';
66
import { parseSSE } from '../../client/stream';
7-
import { detectOutputFormat, formatOutput } from '../../output/formatter';
7+
import { detectOutputFormat, formatOutput, dryRun } from '../../output/formatter';
88
import { saveAudioOutput } from '../../output/audio';
99
import { writeFileSync } from 'fs';
1010
import { readTextFromPathOrStdin } from '../../utils/fs';
@@ -65,7 +65,6 @@ export default defineCommand({
6565
const ext = (flags.format as string) || 'mp3';
6666
const outPath = (flags.out as string | undefined) ?? `speech_${ts}.${ext}`;
6767
const outFormat = 'hex';
68-
const format = detectOutputFormat(config.output);
6968

7069
const body: SpeechRequest = {
7170
model,
@@ -96,11 +95,9 @@ export default defineCommand({
9695
});
9796
}
9897

99-
if (config.dryRun) {
100-
console.log(formatOutput({ request: body }, format));
101-
return;
102-
}
98+
if (dryRun(config, body)) return;
10399

100+
const format = detectOutputFormat(config.output);
104101
const url = speechEndpoint(config.baseUrl);
105102

106103
if (flags.stream) {

src/commands/video/generate.ts

Lines changed: 16 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,12 @@ import { requestJson } from '../../client/http';
55
import { videoGenerateEndpoint, videoTaskEndpoint, fileRetrieveEndpoint } from '../../client/endpoints';
66
import { poll } from '../../polling/poll';
77
import { downloadFile, formatBytes } from '../../files/download';
8-
import { formatOutput, detectOutputFormat } from '../../output/formatter';
8+
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
99
import type { Config } from '../../config/schema';
1010
import type { GlobalFlags } from '../../types/flags';
1111
import type { VideoRequest, VideoResponse, VideoTaskResponse, FileRetrieveResponse } from '../../types/api';
12-
import { readFileSync } from 'fs';
13-
import { extname } from 'path';
14-
15-
const MIME_TYPES: Record<string, string> = {
16-
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
17-
'.png': 'image/png', '.webp': 'image/webp',
18-
};
19-
import { isInteractive } from '../../utils/env';
20-
import { promptText, failIfMissing } from '../../utils/prompt';
12+
import { resolveImageInput } from '../../utils/image';
13+
import { promptOrFail } from '../../utils/prompt';
2114

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

52-
if (!prompt) {
53-
if (isInteractive({ nonInteractive: config.nonInteractive })) {
54-
const hint = await promptText({ message: 'Enter your video prompt:' });
55-
if (!hint) {
56-
process.stderr.write('Video generation cancelled.\n');
57-
process.exit(1);
58-
}
59-
prompt = hint;
60-
} else {
61-
failIfMissing('prompt', 'mmx video generate --prompt <text>');
62-
}
63-
}
45+
prompt = await promptOrFail({
46+
value: prompt,
47+
message: 'Enter your video prompt:',
48+
cancelMessage: 'Video generation cancelled.',
49+
flagName: 'prompt',
50+
usageHint: 'mmx video generate --prompt <text>',
51+
nonInteractive: config.nonInteractive,
52+
});
6453

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

9785
const body: VideoRequest = {
9886
model,
@@ -101,15 +89,7 @@ export default defineCommand({
10189

10290
// First frame (I2V)
10391
if (flags.firstFrame) {
104-
const framePath = flags.firstFrame as string;
105-
if (framePath.startsWith('http')) {
106-
body.first_frame_image = framePath;
107-
} else {
108-
const imgData = readFileSync(framePath);
109-
const ext = extname(framePath).toLowerCase();
110-
const mime = MIME_TYPES[ext] || 'image/jpeg';
111-
body.first_frame_image = `data:${mime};base64,${imgData.toString('base64')}`;
112-
}
92+
body.first_frame_image = resolveImageInput(flags.firstFrame as string);
11393
}
11494

11595
// Last frame (SEF mode)
@@ -121,41 +101,21 @@ export default defineCommand({
121101
'mmx video generate --prompt <text> --first-frame <path> --last-frame <path>',
122102
);
123103
}
124-
const framePath = flags.lastFrame as string;
125-
if (framePath.startsWith('http')) {
126-
body.last_frame_image = framePath;
127-
} else {
128-
const imgData = readFileSync(framePath);
129-
const ext = extname(framePath).toLowerCase();
130-
const mime = MIME_TYPES[ext] || 'image/jpeg';
131-
body.last_frame_image = `data:${mime};base64,${imgData.toString('base64')}`;
132-
}
104+
body.last_frame_image = resolveImageInput(flags.lastFrame as string);
133105
}
134106

135107
// Subject reference (S2V mode)
136108
if (flags.subjectImage) {
137-
const imgPath = flags.subjectImage as string;
138-
let imageData: string;
139-
if (imgPath.startsWith('http')) {
140-
imageData = imgPath;
141-
} else {
142-
const imgData = readFileSync(imgPath);
143-
const ext = extname(imgPath).toLowerCase();
144-
const mime = MIME_TYPES[ext] || 'image/jpeg';
145-
imageData = `data:${mime};base64,${imgData.toString('base64')}`;
146-
}
147-
body.subject_reference = [{ type: 'character', image: [imageData] }];
109+
body.subject_reference = [{ type: 'character', image: [resolveImageInput(flags.subjectImage as string)] }];
148110
}
149111

150112
if (flags.callbackUrl) {
151113
body.callback_url = flags.callbackUrl as string;
152114
}
153115

154-
if (config.dryRun) {
155-
console.log(formatOutput({ request: body }, format));
156-
return;
157-
}
116+
if (dryRun(config, body)) return;
158117

118+
const format = detectOutputFormat(config.output);
159119
const url = videoGenerateEndpoint(config.baseUrl);
160120
const response = await requestJson<VideoResponse>(config, {
161121
url,

src/commands/vision/describe.ts

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,19 @@
11
import { defineCommand } from '../../command';
22
import { requestJson } from '../../client/http';
33
import { vlmEndpoint } from '../../client/endpoints';
4-
import { formatOutput, detectOutputFormat } from '../../output/formatter';
4+
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
55
import { CLIError } from '../../errors/base';
66
import { ExitCode } from '../../errors/codes';
77
import type { Config } from '../../config/schema';
88
import type { GlobalFlags } from '../../types/flags';
9-
import { readFileSync, existsSync } from 'fs';
10-
import { extname } from 'path';
119
import { isInteractive } from '../../utils/env';
1210
import { promptText } from '../../utils/prompt';
11+
import { toDataUri } from '../../utils/image';
1312

1413
interface VlmResponse {
1514
content: string;
1615
}
1716

18-
const MIME_TYPES: Record<string, string> = {
19-
'.jpg': 'image/jpeg',
20-
'.jpeg': 'image/jpeg',
21-
'.png': 'image/png',
22-
'.webp': 'image/webp',
23-
};
24-
25-
const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB limit
26-
27-
export async function toDataUri(image: string): Promise<string> {
28-
if (image.startsWith('data:')) return image;
29-
30-
if (image.startsWith('http://') || image.startsWith('https://')) {
31-
const res = await fetch(image);
32-
if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL);
33-
const contentType = res.headers.get('content-type') || 'image/jpeg';
34-
const mime = contentType.split(';')[0]!.trim();
35-
const buf = await res.arrayBuffer();
36-
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {
37-
throw new CLIError(
38-
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
39-
ExitCode.USAGE,
40-
);
41-
}
42-
const b64 = Buffer.from(buf).toString('base64');
43-
return `data:${mime};base64,${b64}`;
44-
}
45-
46-
// Local file
47-
if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE);
48-
const ext = extname(image).toLowerCase();
49-
const mime = MIME_TYPES[ext];
50-
if (!mime) throw new CLIError(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`, ExitCode.USAGE);
51-
const buf = readFileSync(image);
52-
return `data:${mime};base64,${buf.toString('base64')}`;
53-
}
54-
5517
export default defineCommand({
5618
name: 'vision describe',
5719
description: 'Describe an image using MiniMax VLM',
@@ -101,13 +63,9 @@ export default defineCommand({
10163
);
10264
}
10365

104-
const format = detectOutputFormat(config.output);
105-
106-
if (config.dryRun) {
107-
process.stdout.write(formatOutput({ request: { prompt, image, fileId } }, format) + '\n');
108-
return;
109-
}
66+
if (dryRun(config, { prompt, image, fileId })) return;
11067

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

src/output/formatter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
2121
return formatText(data);
2222
}
2323
}
24+
25+
export function dryRun(config: { dryRun?: boolean; output?: string }, body: unknown): boolean {
26+
if (!config.dryRun) return false;
27+
console.log(formatOutput({ request: body }, detectOutputFormat(config.output)));
28+
return true;
29+
}

src/sdk/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { loadConfig } from "../config/loader";
22
import { Config } from "../config/schema";
33
import { request as requestClient, requestJson as requestJsonClient, RequestOpts } from "../client/http";
4+
import { parseSSE } from "../client/stream";
5+
import { SDKError } from "../errors/base";
6+
import { ExitCode } from "../errors/codes";
47
import { MiniMaxSDKOptions } from "./types";
58

69
export class Client {
@@ -30,4 +33,19 @@ export class Client {
3033
protected requestJson<T>(opts: RequestOpts): Promise<T> {
3134
return requestJsonClient<T>(this.config, opts);
3235
}
36+
37+
protected async *streamSSE<T>(res: Response): AsyncGenerator<T> {
38+
for await (const event of parseSSE(res)) {
39+
if (event.data === '[DONE]') break;
40+
if (!event.data) continue;
41+
try {
42+
yield JSON.parse(event.data) as T;
43+
} catch (err) {
44+
throw new SDKError(
45+
`Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}`,
46+
ExitCode.GENERAL,
47+
);
48+
}
49+
}
50+
}
3351
}

src/sdk/music/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MusicRequest, MusicResponse } from "../../types/api";
44
import { ModelPartial } from "../types";
55
import { SDKError } from "../../errors/base";
66
import { ExitCode } from "../../errors/codes";
7-
import { toMerged } from "es-toolkit";
7+
import { toMerged } from "es-toolkit/object";
88
import { musicGenerateModel } from "../../commands/music/models";
99

1010
export interface MusicGenerateRequest extends MusicRequest {

0 commit comments

Comments
 (0)