Skip to content

Commit f332a62

Browse files
committed
Merge origin/main into fix/image-out-flag
Resolve conflicts: combine --out flag with response_format/base64 support.
2 parents 0f27812 + c6e70f0 commit f332a62

4 files changed

Lines changed: 126 additions & 58 deletions

File tree

skill/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,15 @@ mmx image generate --prompt <text> [flags]
9393
| Flag | Type | Description |
9494
|---|---|---|
9595
| `--prompt <text>` | string, **required** | Image description |
96-
| `--aspect-ratio <ratio>` | string | e.g. `16:9`, `1:1` |
96+
| `--aspect-ratio <ratio>` | string | e.g. `16:9`, `1:1`. Ignored if `--width` and `--height` are both set |
9797
| `--n <count>` | number | Number of images (default: 1) |
98+
| `--seed <n>` | number | Random seed for reproducible generation |
99+
| `--width <px>` | number | Width in pixels (512–2048, multiple of 8). Requires `--height` |
100+
| `--height <px>` | number | Height in pixels (512–2048, multiple of 8). Requires `--width` |
101+
| `--prompt-optimizer` | boolean | Optimize prompt before generation |
102+
| `--aigc-watermark` | boolean | Embed AI-generated content watermark |
98103
| `--subject-ref <params>` | string | Subject reference: `type=character,image=path-or-url` |
104+
| `--response-format <format>` | string | `url` (default) or `base64`. Base64 bypasses CDN download |
99105
| `--out-dir <dir>` | string | Download images to directory |
100106
| `--out-prefix <prefix>` | string | Filename prefix (default: `image`) |
101107

src/commands/image/generate.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ import { formatOutput, detectOutputFormat } 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 } from 'fs';
11+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
1212
import { dirname, join, resolve, extname } from 'path';
13+
import { isInteractive } from '../../utils/env';
14+
import { promptText, failIfMissing } from '../../utils/prompt';
1315

1416
const MIME_TYPES: Record<string, string> = {
1517
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1618
'.png': 'image/png', '.webp': 'image/webp',
1719
};
18-
import { isInteractive } from '../../utils/env';
19-
import { promptText, failIfMissing } from '../../utils/prompt';
2020

2121
export default defineCommand({
2222
name: 'image generate',
@@ -34,6 +34,7 @@ export default defineCommand({
3434
{ flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in the output image.' },
3535
{ flag: '--subject-ref <params>', description: 'Subject reference for character consistency. Format: type=character,image=path-or-url' },
3636
{ flag: '--out <path>', description: 'Save image to exact file path (single image only)' },
37+
{ flag: '--response-format <format>', description: 'Response format: url (download), base64 (embed). Default: url' },
3738
{ flag: '--out-dir <dir>', description: 'Download images to directory' },
3839
{ flag: '--out-prefix <prefix>', description: 'Filename prefix (default: image)' },
3940
],
@@ -49,6 +50,8 @@ export default defineCommand({
4950
'mmx image generate --prompt "sunset" --prompt-optimizer --aigc-watermark',
5051
'# Save to exact path',
5152
'mmx image generate --prompt "A cat" --out /tmp/cat.jpg',
53+
'# Base64 response (bypasses CDN, useful when image URLs are unreachable)',
54+
'mmx image generate --prompt "A cat" --response-format base64',
5255
],
5356
async run(config: Config, flags: GlobalFlags) {
5457
let prompt = (flags.prompt ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined;
@@ -96,6 +99,8 @@ export default defineCommand({
9699
throw new CLIError('--out cannot be used with --n > 1. Use --out-dir instead.', ExitCode.USAGE);
97100
}
98101

102+
const responseFormat = (flags.responseFormat as 'url' | 'base64' | undefined) || 'url';
103+
99104
const body: ImageRequest = {
100105
model: 'image-01',
101106
prompt,
@@ -106,6 +111,7 @@ export default defineCommand({
106111
height: height,
107112
prompt_optimizer: flags.promptOptimizer === true || undefined,
108113
aigc_watermark: flags.aigcWatermark === true || undefined,
114+
response_format: responseFormat,
109115
};
110116

111117
if (flags.subjectRef) {
@@ -151,8 +157,6 @@ export default defineCommand({
151157
body,
152158
});
153159

154-
const imageUrls = response.data.image_urls || [];
155-
156160
if (!config.quiet) {
157161
process.stderr.write('[Model: image-01]\n');
158162
}
@@ -166,21 +170,41 @@ export default defineCommand({
166170
if (existsSync(destPath)) {
167171
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
168172
}
169-
await downloadFile(imageUrls[0]!, destPath, { quiet: config.quiet });
173+
if (responseFormat === 'base64') {
174+
const image = (response.data.image_base64 || [])[0];
175+
if (image) writeFileSync(destPath, image, 'base64');
176+
} else {
177+
const imageUrl = (response.data.image_urls || [])[0];
178+
if (imageUrl) await downloadFile(imageUrl, destPath, { quiet: config.quiet });
179+
}
170180
saved.push(destPath);
171181
} else {
172182
const outDir = (flags.outDir as string | undefined) ?? '.';
173183
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
174184
const prefix = (flags.outPrefix as string) || 'image';
175185

176-
for (let i = 0; i < imageUrls.length; i++) {
177-
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
178-
const destPath = join(outDir, filename);
179-
if (existsSync(destPath)) {
180-
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
186+
if (responseFormat === 'base64') {
187+
const images = response.data.image_base64 || [];
188+
for (let i = 0; i < images.length; i++) {
189+
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
190+
const destPath = join(outDir, filename);
191+
if (existsSync(destPath)) {
192+
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
193+
}
194+
writeFileSync(destPath, images[i]!, 'base64');
195+
saved.push(destPath);
196+
}
197+
} else {
198+
const imageUrls = response.data.image_urls || [];
199+
for (let i = 0; i < imageUrls.length; i++) {
200+
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
201+
const destPath = join(outDir, filename);
202+
if (existsSync(destPath)) {
203+
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
204+
}
205+
await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet });
206+
saved.push(destPath);
181207
}
182-
await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet });
183-
saved.push(destPath);
184208
}
185209
}
186210

src/files/download.ts

Lines changed: 79 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,100 @@ import { createProgressBar } from '../output/progress';
33
import { CLIError } from '../errors/base';
44
import { ExitCode } from '../errors/codes';
55

6+
export interface DownloadOpts {
7+
quiet?: boolean;
8+
retries?: number;
9+
retryDelayMs?: number;
10+
}
11+
612
export async function downloadFile(
713
url: string,
814
destPath: string,
9-
opts?: { quiet?: boolean },
15+
opts?: DownloadOpts,
1016
): Promise<{ size: number }> {
11-
const res = await fetch(url);
17+
// Fix: Alibaba Cloud OSS US East blocks HTTP from certain regions.
18+
// Force HTTPS to ensure reliable downloads.
19+
const downloadUrl = url.startsWith('http://') ? url.replace('http://', 'https://') : url;
20+
const maxRetries = opts?.retries ?? 3;
21+
const baseDelay = opts?.retryDelayMs ?? 1000;
22+
let lastError: Error | undefined;
1223

13-
if (!res.ok) {
14-
throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL);
15-
}
24+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
25+
try {
26+
if (attempt > 0) {
27+
const delay = baseDelay * Math.pow(2, attempt - 1);
28+
if (!opts?.quiet) {
29+
process.stderr.write(`\n Retry ${attempt}/${maxRetries} in ${delay}ms...\n`);
30+
}
31+
await new Promise(r => setTimeout(r, delay));
32+
}
1633

17-
const contentLength = Number(res.headers.get('content-length') || 0);
18-
const reader = res.body?.getReader();
19-
if (!reader) throw new CLIError('No response body', ExitCode.GENERAL);
34+
const res = await fetch(downloadUrl);
2035

21-
const writer = createWriteStream(destPath);
22-
const progress = contentLength > 0 && !opts?.quiet
23-
? createProgressBar(contentLength, 'Downloading')
24-
: null;
36+
if (!res.ok) {
37+
throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL);
38+
}
2539

26-
let received = 0;
27-
let completed = false;
40+
const contentLength = Number(res.headers.get('content-length') || 0);
41+
const reader = res.body?.getReader();
42+
if (!reader) throw new CLIError('No response body', ExitCode.GENERAL);
2843

29-
try {
30-
const writeError = new Promise<never>((_, reject) => {
31-
writer.on('error', reject);
32-
});
44+
const writer = createWriteStream(destPath);
45+
const progress = contentLength > 0 && !opts?.quiet
46+
? createProgressBar(contentLength, 'Downloading')
47+
: null;
3348

34-
while (true) {
35-
const { done, value } = await Promise.race([
36-
reader.read(),
37-
writeError,
38-
]) as ReadableStreamReadResult<Uint8Array>;
39-
if (done) break;
49+
let received = 0;
50+
let completed = false;
4051

41-
const ok = writer.write(value);
42-
if (!ok) await new Promise(r => writer.once('drain', r));
52+
try {
53+
const writeError = new Promise<never>((_, reject) => {
54+
writer.on('error', reject);
55+
});
4356

44-
received += value.byteLength;
45-
progress?.update(received);
46-
}
47-
completed = true;
48-
} finally {
49-
reader.releaseLock();
50-
progress?.finish();
51-
52-
await new Promise<void>((resolve, reject) => {
53-
writer.on('finish', resolve);
54-
writer.on('error', reject);
55-
writer.end();
56-
});
57-
58-
if (!completed) {
59-
try { unlinkSync(destPath); } catch { /* best effort */ }
57+
while (true) {
58+
const { done, value } = await Promise.race([
59+
reader.read(),
60+
writeError,
61+
]) as ReadableStreamReadResult<Uint8Array>;
62+
if (done) break;
63+
64+
const ok = writer.write(value);
65+
if (!ok) await new Promise(r => writer.once('drain', r));
66+
67+
received += value.byteLength;
68+
progress?.update(received);
69+
}
70+
completed = true;
71+
} finally {
72+
reader.releaseLock();
73+
progress?.finish();
74+
75+
await new Promise<void>((resolve, reject) => {
76+
writer.on('finish', resolve);
77+
writer.on('error', reject);
78+
writer.end();
79+
});
80+
81+
if (!completed) {
82+
try { unlinkSync(destPath); } catch { /* best effort */ }
83+
}
84+
}
85+
86+
return { size: received };
87+
} catch (err) {
88+
lastError = err instanceof Error ? err : new Error(String(err));
89+
if (!opts?.quiet) {
90+
process.stderr.write(`\n Download attempt ${attempt + 1} failed: ${lastError.message}\n`);
91+
}
6092
}
6193
}
6294

63-
return { size: received };
95+
throw new CLIError(
96+
`Download failed after ${maxRetries + 1} attempts: ${lastError?.message}`,
97+
ExitCode.NETWORK,
98+
'Check your network connection and proxy settings.',
99+
);
64100
}
65101

66102
export function formatBytes(bytes: number): string {

src/types/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export interface ImageRequest {
154154
height?: number;
155155
prompt_optimizer?: boolean;
156156
aigc_watermark?: boolean;
157+
response_format?: 'url' | 'base64';
157158
subject_reference?: Array<{
158159
type: string;
159160
image_url?: string;
@@ -164,7 +165,8 @@ export interface ImageRequest {
164165
export interface ImageResponse {
165166
base_resp: BaseResp;
166167
data: {
167-
image_urls: string[];
168+
image_urls?: string[];
169+
image_base64?: string[];
168170
task_id: string;
169171
success_count: number;
170172
failed_count: number;

0 commit comments

Comments
 (0)