Skip to content

Commit 9a2bdab

Browse files
fix: force HTTPS for image downloads and add base64 response format
- Auto-convert HTTP to HTTPS in downloadFile() to work around Alibaba Cloud OSS CDN blocking HTTP traffic from certain regions - Add --response-format <url|base64> flag for image generation - Add retry logic (3 attempts, exponential backoff) for download failures - Fixes image download failures with 'Network request failed' error Related: MiniMax API returns http:// URLs but OSS CDN requires https://
1 parent 813ff4c commit 9a2bdab

3 files changed

Lines changed: 107 additions & 54 deletions

File tree

src/commands/image/generate.ts

Lines changed: 25 additions & 10 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 { 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',
@@ -33,6 +33,7 @@ export default defineCommand({
3333
{ flag: '--prompt-optimizer', description: 'Automatically optimize the prompt before generation for better results.' },
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' },
36+
{ flag: '--response-format <format>', description: 'Response format: url (download), base64 (embed). Default: url' },
3637
{ flag: '--out-dir <dir>', description: 'Download images to directory' },
3738
{ flag: '--out-prefix <prefix>', description: 'Filename prefix (default: image)' },
3839
],
@@ -46,6 +47,8 @@ export default defineCommand({
4647
'mmx image generate --prompt "Wide landscape" --width 1920 --height 1080',
4748
'# Optimized prompt with watermark',
4849
'mmx image generate --prompt "sunset" --prompt-optimizer --aigc-watermark',
50+
'# Base64 response (bypasses CDN, useful when image URLs are unreachable)',
51+
'mmx image generate --prompt "A cat" --response-format base64',
4952
],
5053
async run(config: Config, flags: GlobalFlags) {
5154
let prompt = (flags.prompt ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined;
@@ -88,6 +91,8 @@ export default defineCommand({
8891
validateSize('height', height);
8992
}
9093

94+
const responseFormat = (flags.responseFormat as 'url' | 'base64' | undefined) || 'url';
95+
9196
const body: ImageRequest = {
9297
model: 'image-01',
9398
prompt,
@@ -98,6 +103,7 @@ export default defineCommand({
98103
height: height,
99104
prompt_optimizer: flags.promptOptimizer === true || undefined,
100105
aigc_watermark: flags.aigcWatermark === true || undefined,
106+
response_format: responseFormat,
101107
};
102108

103109
if (flags.subjectRef) {
@@ -143,8 +149,6 @@ export default defineCommand({
143149
body,
144150
});
145151

146-
const imageUrls = response.data.image_urls || [];
147-
148152
if (!config.quiet) {
149153
process.stderr.write('[Model: image-01]\n');
150154
}
@@ -155,11 +159,22 @@ export default defineCommand({
155159
const prefix = (flags.outPrefix as string) || 'image';
156160
const saved: string[] = [];
157161

158-
for (let i = 0; i < imageUrls.length; i++) {
159-
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
160-
const destPath = join(outDir, filename);
161-
await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet });
162-
saved.push(destPath);
162+
if (responseFormat === 'base64') {
163+
const images = response.data.image_base64 || [];
164+
for (let i = 0; i < images.length; i++) {
165+
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
166+
const destPath = join(outDir, filename);
167+
writeFileSync(destPath, images[i]!, 'base64');
168+
saved.push(destPath);
169+
}
170+
} else {
171+
const imageUrls = response.data.image_urls || [];
172+
for (let i = 0; i < imageUrls.length; i++) {
173+
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
174+
const destPath = join(outDir, filename);
175+
await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet });
176+
saved.push(destPath);
177+
}
163178
}
164179

165180
if (config.quiet) {

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
@@ -162,6 +162,7 @@ export interface ImageRequest {
162162
height?: number;
163163
prompt_optimizer?: boolean;
164164
aigc_watermark?: boolean;
165+
response_format?: 'url' | 'base64';
165166
subject_reference?: Array<{
166167
type: string;
167168
image_url?: string;
@@ -172,7 +173,8 @@ export interface ImageRequest {
172173
export interface ImageResponse {
173174
base_resp: BaseResp;
174175
data: {
175-
image_urls: string[];
176+
image_urls?: string[];
177+
image_base64?: string[];
176178
task_id: string;
177179
success_count: number;
178180
failed_count: number;

0 commit comments

Comments
 (0)