diff --git a/README.md b/README.md index d749424..2a062d3 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,14 @@ lock a direction in one click, search the full library, or keep the default. No separate OpenAI API key or `.env` file is required for the default Codex workflow. +To use the MiniMax image backend, configure the server environment with +`CODEX_SLIDES_IMAGE_PROVIDER=minimax` and `MINIMAX_API_KEY`. Set +`MINIMAX_API_REGION` to `global_en` or `cn_zh`, and optionally set +`MINIMAX_IMAGE_MODEL` to `image-01` or `image-01-live`. The backend accepts +`MINIMAX_IMAGE_RESPONSE_FORMAT=url` or `base64`; set both +`MINIMAX_IMAGE_WIDTH` and `MINIMAX_IMAGE_HEIGHT` to request explicit dimensions +instead of the project aspect ratio. + ### Install from the GitHub marketplace This repository includes a Codex marketplace manifest, so the repository itself can be added diff --git a/src/lib/image.ts b/src/lib/image.ts new file mode 100644 index 0000000..6ef685a --- /dev/null +++ b/src/lib/image.ts @@ -0,0 +1,52 @@ +import { + generateSlideImage as generateCodexSlideImage, + type GenerateImageOptions as CodexImageOptions, +} from "./codex-image"; +import { + generateMiniMaxImage, + miniMaxImageOptionsFromEnv, + type MiniMaxImageOptions, +} from "./minimax-image"; + +export type ImageProvider = "codex" | "minimax"; + +export interface GenerateImageOptions extends CodexImageOptions { + provider?: ImageProvider; + miniMax?: MiniMaxImageOptions; +} + +export function resolveImageProvider( + configured = process.env.CODEX_SLIDES_IMAGE_PROVIDER, +): ImageProvider { + const provider = configured?.trim().toLowerCase() || "codex"; + if (provider === "codex" || provider === "minimax") return provider; + throw new Error("Unsupported image provider: " + provider); +} + +export function imageProviderSupportsReferences( + configured = process.env.CODEX_SLIDES_IMAGE_PROVIDER, +): boolean { + return resolveImageProvider(configured) === "codex"; +} + +export async function generateSlideImage( + prompt: string, + options: GenerateImageOptions = {}, +): Promise { + const provider = options.provider ?? resolveImageProvider(); + if (provider === "codex") return generateCodexSlideImage(prompt, options); + + if (options.refImages?.length) { + throw new Error("The MiniMax text-to-image backend does not accept reference images"); + } + + const envOptions = miniMaxImageOptionsFromEnv(); + const miniMaxOptions = options.miniMax ?? {}; + return generateMiniMaxImage(prompt, { + ...envOptions, + ...miniMaxOptions, + model: miniMaxOptions.model ?? envOptions.model, + aspectRatio: miniMaxOptions.aspectRatio ?? String(options.aspect ?? "16:9"), + signal: options.signal, + }); +} diff --git a/src/lib/minimax-image.test.mjs b/src/lib/minimax-image.test.mjs new file mode 100644 index 0000000..7f85dd2 --- /dev/null +++ b/src/lib/minimax-image.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildMiniMaxImageRequest, + generateMiniMaxImage, + miniMaxImageOptionsFromEnv, + parseMiniMaxImageResponse, +} from "./minimax-image.ts"; + +function jsonResponse(value) { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("builds a global aspect-ratio request with required fields", async () => { + const calls = []; + const imageBytes = Buffer.from("global-image"); + const bytes = await generateMiniMaxImage("Draw a title slide", { + apiKey: "local-test-key", + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return jsonResponse({ + base_resp: { status_code: 0 }, + data: { image_urls: [imageBytes.toString("base64")] }, + }); + }, + aspectRatio: "16:9", + }); + + assert.deepEqual(bytes, imageBytes); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.minimax.io/v1/image_generation"); + assert.equal(calls[0].init.headers.Authorization, "Bearer local-test-key"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + model: "image-01", + prompt: "Draw a title slide", + response_format: "base64", + aspect_ratio: "16:9", + }); +}); + +test("uses the China endpoint, live model, explicit dimensions, and URL output", async () => { + const calls = []; + const imageBytes = Buffer.from("china-image"); + const bytes = await generateMiniMaxImage("Draw a product slide", { + apiKey: "local-test-key", + region: "cn_zh", + model: "image-01-live", + responseFormat: "url", + width: 1200, + height: 800, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + if (calls.length === 1) { + return jsonResponse({ + base_resp: { status_code: 0 }, + data: { image_urls: ["https://images.example.test/generated.png"] }, + }); + } + return new Response(imageBytes, { status: 200 }); + }, + }); + + assert.deepEqual(bytes, imageBytes); + assert.equal(calls[0].url, "https://api.minimaxi.com/v1/image_generation"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + model: "image-01-live", + prompt: "Draw a product slide", + response_format: "url", + width: 1200, + height: 800, + }); + assert.equal(calls[1].url, "https://images.example.test/generated.png"); +}); + +test("decodes a data URL response", async () => { + const imageBytes = Buffer.from("data-url-image"); + const result = await parseMiniMaxImageResponse({ + base_resp: { status_code: 0 }, + data: { image_urls: ["data:image/png;base64," + imageBytes.toString("base64")] }, + }); + assert.deepEqual(result, imageBytes); +}); + +test("reads the supported backend settings from the environment", () => { + const options = miniMaxImageOptionsFromEnv({ + MINIMAX_API_KEY: "local-test-key", + MINIMAX_API_REGION: "cn_zh", + MINIMAX_IMAGE_MODEL: "image-01-live", + MINIMAX_IMAGE_RESPONSE_FORMAT: "url", + MINIMAX_IMAGE_WIDTH: "1200", + MINIMAX_IMAGE_HEIGHT: "800", + }); + assert.equal(options.apiKey, "local-test-key"); + assert.equal(options.region, "cn_zh"); + assert.equal(options.model, "image-01-live"); + assert.equal(options.responseFormat, "url"); + assert.equal(options.width, 1200); + assert.equal(options.height, 800); +}); + +test("rejects unsupported models and incomplete dimensions", () => { + assert.throws( + () => buildMiniMaxImageRequest("prompt", { apiKey: "local-test-key", model: "unknown-model" }), + /Unsupported MiniMax image model/, + ); + assert.throws( + () => buildMiniMaxImageRequest("prompt", { apiKey: "local-test-key", width: 1200 }), + /width and height must be provided together/, + ); +}); + +test("surfaces a non-zero API status", async () => { + await assert.rejects( + () => parseMiniMaxImageResponse({ + base_resp: { status_code: 1001 }, + data: { image_urls: [] }, + }), + /status code 1001/, + ); +}); diff --git a/src/lib/minimax-image.ts b/src/lib/minimax-image.ts new file mode 100644 index 0000000..f1abe9d --- /dev/null +++ b/src/lib/minimax-image.ts @@ -0,0 +1,221 @@ +export const MINIMAX_IMAGE_ENDPOINTS = { + global_en: "https://api.minimax.io/v1/image_generation", + cn_zh: "https://api.minimaxi.com/v1/image_generation", +} as const; + +export const MINIMAX_IMAGE_MODELS = ["image-01", "image-01-live"] as const; + +export type MiniMaxImageRegion = keyof typeof MINIMAX_IMAGE_ENDPOINTS; +export type MiniMaxImageModel = (typeof MINIMAX_IMAGE_MODELS)[number]; +export type MiniMaxImageResponseFormat = "url" | "base64"; + +export interface MiniMaxImageOptions { + apiKey?: string; + region?: MiniMaxImageRegion; + model?: MiniMaxImageModel; + responseFormat?: MiniMaxImageResponseFormat; + aspectRatio?: string; + width?: number; + height?: number; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +export class MiniMaxImageError extends Error { + status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = "MiniMaxImageError"; + this.status = status; + } +} + +export interface MiniMaxImageRequest { + endpoint: string; + init: RequestInit; +} + +const DEFAULT_REGION: MiniMaxImageRegion = "global_en"; +const DEFAULT_MODEL: MiniMaxImageModel = "image-01"; +const DEFAULT_RESPONSE_FORMAT: MiniMaxImageResponseFormat = "base64"; + +function parseDimension(value: string | undefined, name: string): number | undefined { + if (!value?.trim()) return undefined; + const dimension = Number(value); + if (!Number.isInteger(dimension) || dimension <= 0) { + throw new MiniMaxImageError(name + " must be a positive integer"); + } + return dimension; +} + +function validRegion(value: string): value is MiniMaxImageRegion { + return value in MINIMAX_IMAGE_ENDPOINTS; +} + +function validModel(value: string): value is MiniMaxImageModel { + return (MINIMAX_IMAGE_MODELS as readonly string[]).includes(value); +} + +function validResponseFormat(value: string): value is MiniMaxImageResponseFormat { + return value === "url" || value === "base64"; +} + +export function miniMaxImageOptionsFromEnv( + env: NodeJS.ProcessEnv = process.env, +): MiniMaxImageOptions { + const region = env.MINIMAX_API_REGION?.trim() || DEFAULT_REGION; + const model = env.MINIMAX_IMAGE_MODEL?.trim() || DEFAULT_MODEL; + const responseFormat = env.MINIMAX_IMAGE_RESPONSE_FORMAT?.trim() || DEFAULT_RESPONSE_FORMAT; + + if (!validRegion(region)) { + throw new MiniMaxImageError("Unsupported MiniMax image region: " + region); + } + if (!validModel(model)) { + throw new MiniMaxImageError("Unsupported MiniMax image model: " + model); + } + if (!validResponseFormat(responseFormat)) { + throw new MiniMaxImageError("Unsupported MiniMax image response format: " + responseFormat); + } + + return { + apiKey: env.MINIMAX_API_KEY?.trim() || undefined, + region, + model, + responseFormat, + width: parseDimension(env.MINIMAX_IMAGE_WIDTH, "MINIMAX_IMAGE_WIDTH"), + height: parseDimension(env.MINIMAX_IMAGE_HEIGHT, "MINIMAX_IMAGE_HEIGHT"), + }; +} + +export function buildMiniMaxImageRequest( + prompt: string, + options: MiniMaxImageOptions = {}, +): MiniMaxImageRequest { + const apiKey = options.apiKey?.trim(); + if (!apiKey) throw new MiniMaxImageError("MINIMAX_API_KEY is required for MiniMax image generation"); + if (!prompt.trim()) throw new MiniMaxImageError("MiniMax image prompt must not be empty"); + + const region = options.region ?? DEFAULT_REGION; + const model = options.model ?? DEFAULT_MODEL; + const responseFormat = options.responseFormat ?? DEFAULT_RESPONSE_FORMAT; + if (!validRegion(region)) throw new MiniMaxImageError("Unsupported MiniMax image region: " + region); + if (!validModel(model)) throw new MiniMaxImageError("Unsupported MiniMax image model: " + model); + if (!validResponseFormat(responseFormat)) { + throw new MiniMaxImageError("Unsupported MiniMax image response format: " + responseFormat); + } + + const hasWidth = options.width !== undefined; + const hasHeight = options.height !== undefined; + if (hasWidth !== hasHeight) { + throw new MiniMaxImageError("MiniMax image width and height must be provided together"); + } + if (hasWidth && (!Number.isInteger(options.width) || (options.width as number) <= 0)) { + throw new MiniMaxImageError("MiniMax image width must be a positive integer"); + } + if (hasHeight && (!Number.isInteger(options.height) || (options.height as number) <= 0)) { + throw new MiniMaxImageError("MiniMax image height must be a positive integer"); + } + + const body: Record = { + model, + prompt, + response_format: responseFormat, + }; + if (hasWidth && hasHeight) { + body.width = options.width; + body.height = options.height; + } else { + body.aspect_ratio = options.aspectRatio || "16:9"; + } + + return { + endpoint: MINIMAX_IMAGE_ENDPOINTS[region], + init: { + method: "POST", + headers: { + Authorization: "Bearer " + apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: options.signal, + }, + }; +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function decodeBase64(value: string): Buffer { + const comma = value.indexOf(","); + const encoded = value.startsWith("data:") ? (comma >= 0 ? value.slice(comma + 1) : "") : value; + const normalized = encoded.replace(/\s/g, "").replace(/-/g, "+").replace(/_/g, "/"); + if (!normalized || normalized.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) { + throw new MiniMaxImageError("MiniMax returned an invalid base64 image"); + } + const bytes = Buffer.from(normalized, "base64"); + if (!bytes.length) throw new MiniMaxImageError("MiniMax returned an empty image"); + return bytes; +} + +async function resolveImageValue( + value: string, + fetchImpl: typeof fetch, + signal?: AbortSignal, +): Promise { + const trimmed = value.trim(); + if (/^https?:\/\//i.test(trimmed)) { + const response = await fetchImpl(trimmed, { signal }); + if (!response.ok) { + throw new MiniMaxImageError("MiniMax image download HTTP " + response.status, response.status); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (!bytes.length) throw new MiniMaxImageError("MiniMax returned an empty image"); + return bytes; + } + return decodeBase64(trimmed); +} + +export async function parseMiniMaxImageResponse( + payload: unknown, + options: { fetchImpl?: typeof fetch; signal?: AbortSignal } = {}, +): Promise { + const root = record(payload); + const baseResp = record(root?.base_resp); + const statusCode = baseResp?.status_code; + if (statusCode !== undefined && statusCode !== null && String(statusCode) !== "0") { + throw new MiniMaxImageError("MiniMax image generation failed with status code " + statusCode); + } + + const data = record(root?.data); + const imageUrls = data?.image_urls; + if (!Array.isArray(imageUrls)) { + throw new MiniMaxImageError("MiniMax returned no image URLs"); + } + const imageValue = imageUrls.find((item): item is string => typeof item === "string" && item.trim().length > 0); + if (!imageValue) throw new MiniMaxImageError("MiniMax returned no image"); + return resolveImageValue(imageValue, options.fetchImpl ?? fetch, options.signal); +} + +export async function generateMiniMaxImage( + prompt: string, + options: MiniMaxImageOptions = {}, +): Promise { + const request = buildMiniMaxImageRequest(prompt, options); + const response = await (options.fetchImpl ?? fetch)(request.endpoint, request.init); + if (!response.ok) { + throw new MiniMaxImageError("MiniMax image generation HTTP " + response.status, response.status); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new MiniMaxImageError("MiniMax returned invalid JSON"); + } + return parseMiniMaxImageResponse(payload, { + fetchImpl: options.fetchImpl, + signal: options.signal, + }); +} diff --git a/src/lib/pipeline.ts b/src/lib/pipeline.ts index 97576f1..40ab47b 100644 --- a/src/lib/pipeline.ts +++ b/src/lib/pipeline.ts @@ -2,11 +2,12 @@ // // - TEXT stages run on the selected engine: the zero-config Codex Responses // endpoint (engine "codex") OR a detected local agent CLI (codex/claude/gemini). -// - IMAGE stages always run on Codex's zero-config image backend. +// - IMAGE stages use the configured image backend. // - One image call per slide (the html-video lesson: never ask for the whole // deck in one shot), each isolated so one failure doesn't sink the deck. -import { generateSlideImage } from "./codex-image"; +import { generateSlideImage as generateCodexSlideImage } from "./codex-image"; +import { generateSlideImage, imageProviderSupportsReferences } from "./image"; import { codexJson, codexText, parseLooseJson, type CodexInputAttachment } from "./codex-text"; import { loadCommunityStyleReference } from "./communityReference"; import { ensureCurrentDeckVersion } from "./deckVersions"; @@ -272,8 +273,13 @@ export async function renderProject( if (!project) throw new Error("project not found"); const { config } = project; - const materialBuffers = loadProjectMaterialBuffers(id, project.materials); - const styleReference = await loadCommunityStyleReference(config.template, signal); + const includeImageReferences = imageProviderSupportsReferences(); + const materialBuffers = includeImageReferences + ? loadProjectMaterialBuffers(id, project.materials) + : []; + const styleReference = includeImageReferences + ? await loadCommunityStyleReference(config.template, signal) + : null; const referenceBuffers = styleReference ? [styleReference, ...materialBuffers] : materialBuffers; const projectAttachments = loadProjectInputAttachments(id, project.materials); if (projectAttachments.length) { @@ -553,8 +559,13 @@ export async function regeneratePage( ); page.description = extractSlideText(descRaw); - const materialBuffers = loadProjectMaterialBuffers(id, project.materials); - const styleReference = await loadCommunityStyleReference(project.config.template, signal); + const includeImageReferences = imageProviderSupportsReferences(); + const materialBuffers = includeImageReferences + ? loadProjectMaterialBuffers(id, project.materials) + : []; + const styleReference = includeImageReferences + ? await loadCommunityStyleReference(project.config.template, signal) + : null; const referenceBuffers = styleReference ? [styleReference, ...materialBuffers] : materialBuffers; const imgPrompt = buildImagePrompt( project.config, @@ -608,7 +619,7 @@ export async function markEditPage( if (!page) throw new Error("page not found"); const prompt = buildMarkEditPrompt(project.config, note); - const bytes = await generateSlideImage(prompt, { + const bytes = await generateCodexSlideImage(prompt, { // The annotation canvas already contains the complete original slide. A // second, clean copy used to compete with it and frequently made the model // return the untouched original instead of following the red marks.