Skip to content

Commit 482b1fc

Browse files
NianJiuZstclaude
andcommitted
feat: add image download and save support to ImageSDK
Add a `save()` method to ImageSDK that downloads generated images to disk, matching the CLI's --out / --out-dir / --out-prefix behavior: - save(response, { out }) — save single image to exact path - save(response, { outDir, prefix? }) — save multiple images with auto-numbered filenames (image_001.jpg, image_002.jpg, ...) - Handles both "url" (CDN download via downloadFile) and "base64" (direct decode) response formats - Creates intermediate directories as needed - Throws when `out` is used with multi-image responses Also export the `ImageSaveOptions` interface for TypeScript users. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b523aa commit 482b1fc

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

src/sdk/image/index.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2+
import { join, resolve, dirname } from 'node:path';
13
import { Client } from "../client";
24
import { imageEndpoint } from "../../client/endpoints";
35
import { ImageRequest, ImageResponse } from "../../types/api";
46
import { ModelPartial } from "../types";
57
import { SDKError } from "../../errors/base";
68
import { ExitCode } from "../../errors/codes";
9+
import { downloadFile } from "../../files/download";
710
import { toMerged } from 'es-toolkit/object';
811

12+
export interface ImageSaveOptions {
13+
/** Save to exact file path (single image only). */
14+
out?: string;
15+
/** Save images to directory (default: "."). */
16+
outDir?: string;
17+
/** Filename prefix (default: "image"). */
18+
prefix?: string;
19+
/** Response format used by generate() — "url" or "base64" (default: "url"). */
20+
responseFormat?: 'url' | 'base64';
21+
/** Suppress progress output. */
22+
quiet?: boolean;
23+
}
24+
925
export class ImageSDK extends Client {
1026
async generate(request: ModelPartial<ImageRequest>): Promise<ImageResponse> {
1127
const body = this.validateParams(request);
@@ -18,6 +34,69 @@ export class ImageSDK extends Client {
1834
});
1935
}
2036

37+
/**
38+
* Download and save images from a `generate()` response to disk.
39+
*
40+
* Handles both `"url"` (CDN download) and `"base64"` response formats
41+
* and creates intermediate directories as needed. Returns the absolute
42+
* paths of all saved files.
43+
*/
44+
async save(response: ImageResponse, options: ImageSaveOptions = {}): Promise<string[]> {
45+
const fmt = options.responseFormat || 'url';
46+
const quiet = options.quiet ?? true;
47+
const saved: string[] = [];
48+
49+
if (options.out) {
50+
// Single-image, exact path
51+
const count = (fmt === 'base64'
52+
? (response.data.image_base64 || []).length
53+
: (response.data.image_urls || []).length);
54+
55+
if (count > 1) {
56+
throw new SDKError(
57+
'Cannot use `out` with multiple images. Use `outDir` instead.',
58+
ExitCode.USAGE,
59+
);
60+
}
61+
62+
const destPath = resolve(options.out);
63+
const dir = dirname(destPath);
64+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65+
66+
if (fmt === 'base64') {
67+
const image = (response.data.image_base64 || [])[0];
68+
if (image) writeFileSync(destPath, image, 'base64');
69+
} else {
70+
const imageUrl = (response.data.image_urls || [])[0];
71+
if (imageUrl) await downloadFile(imageUrl, destPath, { quiet });
72+
}
73+
saved.push(destPath);
74+
} else {
75+
// Multi-image, numbered filenames in a directory
76+
const outDir = resolve(options.outDir || '.');
77+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
78+
const prefix = options.prefix || 'image';
79+
80+
if (fmt === 'base64') {
81+
const images = response.data.image_base64 || [];
82+
for (let i = 0; i < images.length; i++) {
83+
const destPath = join(outDir, `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`);
84+
writeFileSync(destPath, images[i]!, 'base64');
85+
saved.push(destPath);
86+
}
87+
} else {
88+
const imageUrls = response.data.image_urls || [];
89+
for (let i = 0; i < imageUrls.length; i++) {
90+
const destPath = join(outDir, `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`);
91+
await downloadFile(imageUrls[i]!, destPath, { quiet });
92+
saved.push(destPath);
93+
}
94+
}
95+
}
96+
97+
return saved;
98+
}
99+
21100
private validateParams(params: Partial<ImageRequest>): ImageRequest {
22101
const { width, height, aspect_ratio } = params;
23102

test/sdk/image.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
import { describe, it, expect, afterEach } from 'bun:test';
22
import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server';
33
import { MiniMaxSDK } from '../../src/sdk';
4+
import { ImageSDK, ImageSaveOptions } from '../../src/sdk/image';
5+
import { existsSync, unlinkSync, rmdirSync } from 'node:fs';
6+
import { join } from 'node:path';
7+
import { tmpdir } from 'node:os';
8+
import type { ImageResponse } from '../../src/types/api';
9+
10+
function makeBase64Response(images: string[]): ImageResponse {
11+
return {
12+
base_resp: { status_code: 0, status_msg: 'ok' },
13+
data: {
14+
image_base64: images,
15+
task_id: 'task-456',
16+
success_count: images.length,
17+
failed_count: 0,
18+
},
19+
};
20+
}
21+
22+
function makeUrlResponse(urls: string[]): ImageResponse {
23+
return {
24+
base_resp: { status_code: 0, status_msg: 'ok' },
25+
data: {
26+
image_urls: urls,
27+
task_id: 'task-123',
28+
success_count: urls.length,
29+
failed_count: 0,
30+
},
31+
};
32+
}
433

534
describe('MiniMaxSDK.image', () => {
635
let server: MockServer;
@@ -37,3 +66,58 @@ describe('MiniMaxSDK.image', () => {
3766
expect(result.data.task_id).toBe('img-123');
3867
});
3968
});
69+
70+
describe('ImageSDK.save', () => {
71+
const sdk = new ImageSDK({ apiKey: 'sk-test', region: 'global' });
72+
73+
it('saves a single base64 image with `out` option', async () => {
74+
const tmpFile = join(tmpdir(), `mmx-sdk-save-${Date.now()}.jpg`);
75+
const response = makeBase64Response(['/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA==']);
76+
77+
const saved = await sdk.save(response, { out: tmpFile, responseFormat: 'base64' });
78+
79+
expect(saved).toEqual([tmpFile]);
80+
expect(existsSync(tmpFile)).toBe(true);
81+
unlinkSync(tmpFile);
82+
});
83+
84+
it('saves multiple base64 images to a directory', async () => {
85+
const tmpDir = join(tmpdir(), `mmx-sdk-imgs-${Date.now()}`);
86+
const response = makeBase64Response([
87+
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA==',
88+
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA==',
89+
]);
90+
91+
const saved = await sdk.save(response, {
92+
outDir: tmpDir,
93+
prefix: 'test',
94+
responseFormat: 'base64',
95+
});
96+
97+
expect(saved.length).toBe(2);
98+
expect(saved[0]).toContain('test_001.jpg');
99+
expect(saved[1]).toContain('test_002.jpg');
100+
saved.forEach(p => expect(existsSync(p)).toBe(true));
101+
saved.forEach(p => unlinkSync(p));
102+
rmdirSync(tmpDir);
103+
});
104+
105+
it('throws when `out` is used with multiple images', async () => {
106+
const response = makeUrlResponse([
107+
'https://example.com/img1.jpg',
108+
'https://example.com/img2.jpg',
109+
]);
110+
await expect(
111+
sdk.save(response, { out: '/tmp/single.jpg', responseFormat: 'url' }),
112+
).rejects.toThrow('Cannot use `out` with multiple images');
113+
});
114+
115+
it('uses current directory by default', async () => {
116+
const response = makeBase64Response(['/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA==']);
117+
const saved = await sdk.save(response, { responseFormat: 'base64' });
118+
expect(saved.length).toBe(1);
119+
expect(saved[0]).toContain('image_001.jpg');
120+
expect(existsSync(saved[0]!)).toBe(true);
121+
unlinkSync(saved[0]!);
122+
});
123+
});

0 commit comments

Comments
 (0)