diff --git a/src/commands/image/generate.ts b/src/commands/image/generate.ts index 7ff5b5f..aa0b931 100644 --- a/src/commands/image/generate.ts +++ b/src/commands/image/generate.ts @@ -8,15 +8,15 @@ import { formatOutput, detectOutputFormat } 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 } from 'fs'; +import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs'; import { join, resolve, extname } from 'path'; +import { isInteractive } from '../../utils/env'; +import { promptText, failIfMissing } from '../../utils/prompt'; const MIME_TYPES: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', }; -import { isInteractive } from '../../utils/env'; -import { promptText, failIfMissing } from '../../utils/prompt'; export default defineCommand({ name: 'image generate', @@ -33,6 +33,7 @@ export default defineCommand({ { flag: '--prompt-optimizer', description: 'Automatically optimize the prompt before generation for better results.' }, { flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in the output image.' }, { flag: '--subject-ref ', description: 'Subject reference for character consistency. Format: type=character,image=path-or-url' }, + { flag: '--response-format ', description: 'Response format: url (download), base64 (embed). Default: url' }, { flag: '--out-dir ', description: 'Download images to directory' }, { flag: '--out-prefix ', description: 'Filename prefix (default: image)' }, ], @@ -46,6 +47,8 @@ export default defineCommand({ 'mmx image generate --prompt "Wide landscape" --width 1920 --height 1080', '# Optimized prompt with watermark', 'mmx image generate --prompt "sunset" --prompt-optimizer --aigc-watermark', + '# Base64 response (bypasses CDN, useful when image URLs are unreachable)', + 'mmx image generate --prompt "A cat" --response-format base64', ], async run(config: Config, flags: GlobalFlags) { let prompt = (flags.prompt ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined; @@ -88,6 +91,8 @@ export default defineCommand({ validateSize('height', height); } + const responseFormat = (flags.responseFormat as 'url' | 'base64' | undefined) || 'url'; + const body: ImageRequest = { model: 'image-01', prompt, @@ -98,6 +103,7 @@ export default defineCommand({ height: height, prompt_optimizer: flags.promptOptimizer === true || undefined, aigc_watermark: flags.aigcWatermark === true || undefined, + response_format: responseFormat, }; if (flags.subjectRef) { @@ -143,8 +149,6 @@ export default defineCommand({ body, }); - const imageUrls = response.data.image_urls || []; - if (!config.quiet) { process.stderr.write('[Model: image-01]\n'); } @@ -155,11 +159,22 @@ export default defineCommand({ const prefix = (flags.outPrefix as string) || 'image'; const saved: string[] = []; - for (let i = 0; i < imageUrls.length; i++) { - const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`; - const destPath = join(outDir, filename); - await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet }); - saved.push(destPath); + if (responseFormat === 'base64') { + const images = response.data.image_base64 || []; + for (let i = 0; i < images.length; i++) { + const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`; + const destPath = join(outDir, filename); + writeFileSync(destPath, images[i]!, 'base64'); + saved.push(destPath); + } + } else { + const imageUrls = response.data.image_urls || []; + for (let i = 0; i < imageUrls.length; i++) { + const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`; + const destPath = join(outDir, filename); + await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet }); + saved.push(destPath); + } } if (config.quiet) { diff --git a/src/files/download.ts b/src/files/download.ts index 4562fb1..5814023 100644 --- a/src/files/download.ts +++ b/src/files/download.ts @@ -3,64 +3,100 @@ import { createProgressBar } from '../output/progress'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; +export interface DownloadOpts { + quiet?: boolean; + retries?: number; + retryDelayMs?: number; +} + export async function downloadFile( url: string, destPath: string, - opts?: { quiet?: boolean }, + opts?: DownloadOpts, ): Promise<{ size: number }> { - const res = await fetch(url); + // Fix: Alibaba Cloud OSS US East blocks HTTP from certain regions. + // Force HTTPS to ensure reliable downloads. + const downloadUrl = url.startsWith('http://') ? url.replace('http://', 'https://') : url; + const maxRetries = opts?.retries ?? 3; + const baseDelay = opts?.retryDelayMs ?? 1000; + let lastError: Error | undefined; - if (!res.ok) { - throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); - } + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = baseDelay * Math.pow(2, attempt - 1); + if (!opts?.quiet) { + process.stderr.write(`\n Retry ${attempt}/${maxRetries} in ${delay}ms...\n`); + } + await new Promise(r => setTimeout(r, delay)); + } - const contentLength = Number(res.headers.get('content-length') || 0); - const reader = res.body?.getReader(); - if (!reader) throw new CLIError('No response body', ExitCode.GENERAL); + const res = await fetch(downloadUrl); - const writer = createWriteStream(destPath); - const progress = contentLength > 0 && !opts?.quiet - ? createProgressBar(contentLength, 'Downloading') - : null; + if (!res.ok) { + throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); + } - let received = 0; - let completed = false; + const contentLength = Number(res.headers.get('content-length') || 0); + const reader = res.body?.getReader(); + if (!reader) throw new CLIError('No response body', ExitCode.GENERAL); - try { - const writeError = new Promise((_, reject) => { - writer.on('error', reject); - }); + const writer = createWriteStream(destPath); + const progress = contentLength > 0 && !opts?.quiet + ? createProgressBar(contentLength, 'Downloading') + : null; - while (true) { - const { done, value } = await Promise.race([ - reader.read(), - writeError, - ]) as ReadableStreamReadResult; - if (done) break; + let received = 0; + let completed = false; - const ok = writer.write(value); - if (!ok) await new Promise(r => writer.once('drain', r)); + try { + const writeError = new Promise((_, reject) => { + writer.on('error', reject); + }); - received += value.byteLength; - progress?.update(received); - } - completed = true; - } finally { - reader.releaseLock(); - progress?.finish(); - - await new Promise((resolve, reject) => { - writer.on('finish', resolve); - writer.on('error', reject); - writer.end(); - }); - - if (!completed) { - try { unlinkSync(destPath); } catch { /* best effort */ } + while (true) { + const { done, value } = await Promise.race([ + reader.read(), + writeError, + ]) as ReadableStreamReadResult; + if (done) break; + + const ok = writer.write(value); + if (!ok) await new Promise(r => writer.once('drain', r)); + + received += value.byteLength; + progress?.update(received); + } + completed = true; + } finally { + reader.releaseLock(); + progress?.finish(); + + await new Promise((resolve, reject) => { + writer.on('finish', resolve); + writer.on('error', reject); + writer.end(); + }); + + if (!completed) { + try { unlinkSync(destPath); } catch { /* best effort */ } + } + } + + return { size: received }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (!opts?.quiet) { + process.stderr.write(`\n Download attempt ${attempt + 1} failed: ${lastError.message}\n`); + } } } - return { size: received }; + throw new CLIError( + `Download failed after ${maxRetries + 1} attempts: ${lastError?.message}`, + ExitCode.NETWORK, + 'Check your network connection and proxy settings.', + ); } export function formatBytes(bytes: number): string { diff --git a/src/types/api.ts b/src/types/api.ts index b2b0578..4cc50fb 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -162,6 +162,7 @@ export interface ImageRequest { height?: number; prompt_optimizer?: boolean; aigc_watermark?: boolean; + response_format?: 'url' | 'base64'; subject_reference?: Array<{ type: string; image_url?: string; @@ -172,7 +173,8 @@ export interface ImageRequest { export interface ImageResponse { base_resp: BaseResp; data: { - image_urls: string[]; + image_urls?: string[]; + image_base64?: string[]; task_id: string; success_count: number; failed_count: number;