feat(baoyu-image-gen): add MiniMax provider with subject reference and custom sizes

This commit is contained in:
Jim Liu 宝玉
2026-03-25 15:39:40 -05:00
parent 86a3d6521b
commit ad8781c1c5
10 changed files with 702 additions and 34 deletions
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildMinimaxUrl,
buildRequestBody,
buildSubjectReference,
extractImageFromResponse,
parsePixelSize,
validateArgs,
} from "./minimax.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
test("MiniMax URL builder normalizes /v1 suffixes", (t) => {
useEnv(t, { MINIMAX_BASE_URL: "https://api.minimax.io" });
assert.equal(buildMinimaxUrl(), "https://api.minimax.io/v1/image_generation");
process.env.MINIMAX_BASE_URL = "https://proxy.example.com/custom/v1/";
assert.equal(buildMinimaxUrl(), "https://proxy.example.com/custom/v1/image_generation");
});
test("MiniMax size parsing and validation follow documented constraints", () => {
assert.deepEqual(parsePixelSize("1536x1024"), { width: 1536, height: 1024 });
assert.deepEqual(parsePixelSize("1536*1024"), { width: 1536, height: 1024 });
assert.equal(parsePixelSize("wide"), null);
validateArgs("image-01", makeArgs({ size: "1536x1024", n: 9 }));
assert.throws(
() => validateArgs("image-01-live", makeArgs({ size: "1536x1024" })),
/only supported with model image-01/,
);
assert.throws(
() => validateArgs("image-01", makeArgs({ size: "1537x1024" })),
/divisible by 8/,
);
assert.throws(
() => validateArgs("image-01", makeArgs({ aspectRatio: "2.35:1" })),
/aspect_ratio must be one of/,
);
assert.throws(
() => validateArgs("image-01", makeArgs({ n: 10 })),
/at most 9 images/,
);
});
test("MiniMax request body maps aspect ratio, size, n, and subject references", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-test-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const refPath = path.join(dir, "portrait.png");
await fs.writeFile(refPath, Buffer.from("portrait"));
const ratioBody = await buildRequestBody(
"A portrait by the window",
"image-01",
makeArgs({ aspectRatio: "16:9", n: 2, referenceImages: [refPath] }),
);
assert.equal(ratioBody.aspect_ratio, "16:9");
assert.equal(ratioBody.n, 2);
assert.equal(ratioBody.response_format, "base64");
assert.match(ratioBody.subject_reference?.[0]?.image_file || "", /^data:image\/png;base64,/);
const sizeBody = await buildRequestBody(
"A portrait by the window",
"image-01",
makeArgs({ size: "1536x1024" }),
);
assert.equal(sizeBody.width, 1536);
assert.equal(sizeBody.height, 1024);
assert.equal(sizeBody.aspect_ratio, undefined);
});
test("MiniMax subject references require supported file types", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-ref-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const good = path.join(dir, "portrait.jpg");
const bad = path.join(dir, "portrait.webp");
await fs.writeFile(good, Buffer.from("portrait"));
await fs.writeFile(bad, Buffer.from("portrait"));
const subjectReference = await buildSubjectReference([good]);
assert.equal(subjectReference?.[0]?.type, "character");
await assert.rejects(
() => buildSubjectReference([bad]),
/only supports JPG, JPEG, or PNG/,
);
});
test("MiniMax response extraction supports base64 and URL payloads", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const fromBase64 = await extractImageFromResponse({
data: {
image_base64: [Buffer.from("hello").toString("base64")],
},
});
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
globalThis.fetch = async () =>
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "Content-Type": "image/jpeg" },
});
const fromUrl = await extractImageFromResponse({
data: {
image_urls: ["https://example.com/output.jpg"],
},
});
assert.deepEqual([...fromUrl], [1, 2, 3]);
await assert.rejects(
() => extractImageFromResponse({ base_resp: { status_code: 1001, status_msg: "blocked" } }),
/blocked/,
);
});
@@ -0,0 +1,220 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "image-01";
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]);
type MinimaxSubjectReference = {
type: "character";
image_file: string;
};
type MinimaxRequestBody = {
model: string;
prompt: string;
response_format: "base64";
aspect_ratio?: string;
width?: number;
height?: number;
n?: number;
subject_reference?: MinimaxSubjectReference[];
};
type MinimaxResponse = {
id?: string;
data?: {
image_urls?: string[];
image_base64?: string[];
};
base_resp?: {
status_code?: number;
status_msg?: string;
};
};
export function getDefaultModel(): string {
return process.env.MINIMAX_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiKey(): string | null {
return process.env.MINIMAX_API_KEY || null;
}
export function buildMinimaxUrl(): string {
const base = (process.env.MINIMAX_BASE_URL || "https://api.minimax.io").replace(/\/+$/g, "");
return base.endsWith("/v1") ? `${base}/image_generation` : `${base}/v1/image_generation`;
}
function getMimeType(filename: string): "image/jpeg" | "image/png" {
const ext = path.extname(filename).toLowerCase();
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".png") return "image/png";
throw new Error(
`MiniMax subject_reference only supports JPG, JPEG, or PNG files: ${filename}`
);
}
export function parsePixelSize(size: string): { width: number; height: number } | null {
const match = size.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (!match) return null;
const width = parseInt(match[1]!, 10);
const height = parseInt(match[2]!, 10);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
function validatePixelSize(width: number, height: number): void {
if (width < 512 || width > 2048 || height < 512 || height > 2048) {
throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
}
if (width % 8 !== 0 || height % 8 !== 0) {
throw new Error("MiniMax custom size requires width and height divisible by 8.");
}
}
export function validateArgs(model: string, args: CliArgs): void {
if (args.n > 9) {
throw new Error("MiniMax supports at most 9 images per request.");
}
if (args.aspectRatio && !SUPPORTED_ASPECT_RATIOS.has(args.aspectRatio)) {
throw new Error(
`MiniMax aspect_ratio must be one of: ${Array.from(SUPPORTED_ASPECT_RATIOS).join(", ")}.`
);
}
if (args.size && !args.aspectRatio) {
if (model !== "image-01") {
throw new Error("MiniMax custom --size is only supported with model image-01. Use --model image-01 or pass --ar instead.");
}
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
}
validatePixelSize(parsed.width, parsed.height);
}
}
export async function buildSubjectReference(
referenceImages: string[],
): Promise<MinimaxSubjectReference[] | undefined> {
if (referenceImages.length === 0) return undefined;
const subjectReference: MinimaxSubjectReference[] = [];
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
if (bytes.length > MAX_REFERENCE_IMAGE_BYTES) {
throw new Error(`MiniMax subject_reference images must be smaller than 10MB: ${refPath}`);
}
subjectReference.push({
type: "character",
image_file: `data:${getMimeType(refPath)};base64,${bytes.toString("base64")}`,
});
}
return subjectReference;
}
export async function buildRequestBody(
prompt: string,
model: string,
args: CliArgs,
): Promise<MinimaxRequestBody> {
validateArgs(model, args);
const body: MinimaxRequestBody = {
model,
prompt,
response_format: "base64",
};
if (args.aspectRatio) {
body.aspect_ratio = args.aspectRatio;
} else if (args.size) {
const parsed = parsePixelSize(args.size);
if (!parsed) {
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
}
body.width = parsed.width;
body.height = parsed.height;
}
if (args.n > 1) {
body.n = args.n;
}
const subjectReference = await buildSubjectReference(args.referenceImages);
if (subjectReference) {
body.subject_reference = subjectReference;
}
return body;
}
async function downloadImage(url: string): Promise<Uint8Array> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image from MiniMax: ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
}
export async function extractImageFromResponse(result: MinimaxResponse): Promise<Uint8Array> {
const baseResp = result.base_resp;
if (baseResp && baseResp.status_code !== undefined && baseResp.status_code !== 0) {
throw new Error(baseResp.status_msg || `MiniMax API returned status_code=${baseResp.status_code}`);
}
const base64Image = result.data?.image_base64?.[0];
if (base64Image) {
return Uint8Array.from(Buffer.from(base64Image, "base64"));
}
const url = result.data?.image_urls?.[0];
if (url) {
return downloadImage(url);
}
throw new Error("No image data in MiniMax response");
}
export function getDefaultOutputExtension(): ".jpg" {
return ".jpg";
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error("MINIMAX_API_KEY is required. Get one from https://platform.minimax.io/");
}
const body = await buildRequestBody(prompt, model, args);
const response = await fetch(buildMinimaxUrl(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`MiniMax API error (${response.status}): ${err}`);
}
const result = (await response.json()) as MinimaxResponse;
return extractImageFromResponse(result);
}