Skip to content
Open
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
35 changes: 25 additions & 10 deletions src/commands/image/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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';

export default defineCommand({
name: 'image generate',
Expand All @@ -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 <params>', description: 'Subject reference for character consistency. Format: type=character,image=path-or-url' },
{ flag: '--response-format <format>', description: 'Response format: url (download), base64 (embed). Default: url' },
{ flag: '--out-dir <dir>', description: 'Download images to directory' },
{ flag: '--out-prefix <prefix>', description: 'Filename prefix (default: image)' },
],
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -143,8 +149,6 @@ export default defineCommand({
body,
});

const imageUrls = response.data.image_urls || [];

if (!config.quiet) {
process.stderr.write('[Model: image-01]\n');
}
Expand All @@ -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) {
Expand Down
122 changes: 79 additions & 43 deletions src/files/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>((_, 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<Uint8Array>;
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<never>((_, reject) => {
writer.on('error', reject);
});

received += value.byteLength;
progress?.update(received);
}
completed = true;
} finally {
reader.releaseLock();
progress?.finish();

await new Promise<void>((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<Uint8Array>;
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<void>((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 {
Expand Down
4 changes: 3 additions & 1 deletion src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down