Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate images via OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream, and Replicate APIs with batch support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/main.ts
1import path from "node:path";2import process from "node:process";3import { homedir } from "node:os";4import { fileURLToPath } from "node:url";5import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";6import type {7BatchFile,8BatchTaskInput,9CliArgs,10ExtendConfig,11OpenAIImageApiDialect,12Provider,13} from "./types";1415type ProviderModule = {16getDefaultModel: () => string;17generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;18validateArgs?: (model: string, args: CliArgs) => void;19getDefaultOutputExtension?: (model: string, args: CliArgs) => string;20};2122type PreparedTask = {23id: string;24prompt: string;25args: CliArgs;26provider: Provider;27model: string;28outputPath: string;29providerModule: ProviderModule;30};3132type TaskResult = {33id: string;34provider: Provider;35model: string;36outputPath: string;37success: boolean;38attempts: number;39error: string | null;40};4142type ProviderRateLimit = {43concurrency: number;44startIntervalMs: number;45};4647type LoadedBatchTasks = {48tasks: BatchTaskInput[];49jobs: number | null;50batchDir: string;51};5253const MAX_ATTEMPTS = 3;54const DEFAULT_MAX_WORKERS = 10;55const POLL_WAIT_MS = 250;56const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {57replicate: { concurrency: 5, startIntervalMs: 700 },58google: { concurrency: 3, startIntervalMs: 1100 },59openai: { concurrency: 3, startIntervalMs: 1100 },60openrouter: { concurrency: 3, startIntervalMs: 1100 },61dashscope: { concurrency: 3, startIntervalMs: 1100 },62zai: { concurrency: 3, startIntervalMs: 1100 },63minimax: { concurrency: 3, startIntervalMs: 1100 },64jimeng: { concurrency: 3, startIntervalMs: 1100 },65seedream: { concurrency: 3, startIntervalMs: 1100 },66azure: { concurrency: 3, startIntervalMs: 1100 },67"codex-cli": { concurrency: 1, startIntervalMs: 2000 },68agnes: { concurrency: 3, startIntervalMs: 1100 },69};7071function printUsage(): void {72console.log(`Usage:73npx -y bun scripts/main.ts --prompt "A cat" --image cat.png74npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png75npx -y bun scripts/main.ts --batchfile batch.json7677Options:78-p, --prompt <text> Prompt text79--promptfiles <files...> Read prompt from files (concatenated)80--image <path> Output image path (required in single-image mode)81--batchfile <path> JSON batch file for multi-image generation82--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)83--provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure|codex-cli|agnes Force provider (auto-detect by default)84-m, --model <id> Model ID85--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)86--size <WxH> Size (e.g., 1024x1024)87--quality normal|2k Quality preset (default: 2k)88--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)89--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata90--response-format file|url Output mode: file (download image, default) or url (return URL text)91--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*)92--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)93--json JSON output94-h, --help Show help9596Batch file format:97{98"jobs": 4,99"tasks": [100{101"id": "hero",102"promptFiles": ["prompts/hero.md"],103"image": "out/hero.png",104"provider": "replicate",105"model": "google/nano-banana-2",106"ar": "16:9"107}108]109}110111Behavior:112- Batch mode automatically runs in parallel when pending tasks >= 2113- Each image retries automatically up to 3 attempts114- Batch summary reports success count, failure count, and per-image errors115- Replicate currently supports single-image save semantics only; --n must stay at 1116117Environment variables:118OPENAI_API_KEY OpenAI API key119OPENROUTER_API_KEY OpenRouter API key120GOOGLE_API_KEY Google API key121GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)122DASHSCOPE_API_KEY DashScope API key123ZAI_API_KEY Z.AI API key124BIGMODEL_API_KEY Backward-compatible alias for Z.AI API key125MINIMAX_API_KEY MiniMax API key126REPLICATE_API_TOKEN Replicate API token127JIMENG_ACCESS_KEY_ID Jimeng Access Key ID128JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key129ARK_API_KEY Seedream/Ark API key130OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-2)131OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image)132GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image)133DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)134ZAI_IMAGE_MODEL Default Z.AI model (glm-image)135BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)136MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)137REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-2)138JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)139SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)140OPENAI_BASE_URL Custom OpenAI endpoint141OPENAI_IMAGE_API_DIALECT OpenAI-compatible image dialect (openai-native|ratio-metadata)142OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)143OPENROUTER_BASE_URL Custom OpenRouter endpoint144OPENROUTER_HTTP_REFERER Optional app URL for OpenRouter attribution145OPENROUTER_TITLE Optional app name for OpenRouter attribution146GOOGLE_BASE_URL Custom Google endpoint147DASHSCOPE_BASE_URL Custom DashScope endpoint148ZAI_BASE_URL Custom Z.AI endpoint149BIGMODEL_BASE_URL Backward-compatible alias for Z.AI endpoint150MINIMAX_BASE_URL Custom MiniMax endpoint151REPLICATE_BASE_URL Custom Replicate endpoint152JIMENG_BASE_URL Custom Jimeng endpoint153AZURE_OPENAI_API_KEY Azure OpenAI API key154AZURE_OPENAI_BASE_URL Azure OpenAI resource or deployment endpoint155AZURE_OPENAI_DEPLOYMENT Default Azure deployment name156AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)157AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2)158SEEDREAM_BASE_URL Custom Seedream endpoint159BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap160BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency (use underscores: BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY)161BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms162BAOYU_CODEX_IMAGEGEN_BIN Path to codex-imagegen wrapper (default: bundled scripts/codex-imagegen/main.ts; accepts .ts or legacy .sh/binary)163BAOYU_CODEX_IMAGEGEN_CACHE_DIR Enable idempotency cache for codex-cli provider (default: disabled)164BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS Per-attempt codex exec timeout for codex-cli provider (default: 300000)165BAOYU_CODEX_IMAGEGEN_RETRIES Codex-side retry attempts on retryable errors (default: 2)166BAOYU_CODEX_IMAGEGEN_LOG_FILE Append JSONL diagnostic log for codex-cli provider167168Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);169}170171export function parseArgs(argv: string[]): CliArgs {172const out: CliArgs = {173prompt: null,174promptFiles: [],175imagePath: null,176provider: null,177model: null,178aspectRatio: null,179aspectRatioSource: null,180size: null,181quality: null,182imageSize: null,183imageSizeSource: null,184imageApiDialect: null,185responseFormat: null,186referenceImages: [],187n: 1,188batchFile: null,189jobs: null,190json: false,191help: false,192};193194const positional: string[] = [];195196const takeMany = (i: number): { items: string[]; next: number } => {197const items: string[] = [];198let j = i + 1;199while (j < argv.length) {200const v = argv[j]!;201if (v.startsWith("-")) break;202items.push(v);203j++;204}205return { items, next: j - 1 };206};207208for (let i = 0; i < argv.length; i++) {209const a = argv[i]!;210211if (a === "--help" || a === "-h") {212out.help = true;213continue;214}215216if (a === "--json") {217out.json = true;218continue;219}220221if (a === "--prompt" || a === "-p") {222const v = argv[++i];223if (!v) throw new Error(`Missing value for ${a}`);224out.prompt = v;225continue;226}227228if (a === "--promptfiles") {229const { items, next } = takeMany(i);230if (items.length === 0) throw new Error("Missing files for --promptfiles");231out.promptFiles.push(...items);232i = next;233continue;234}235236if (a === "--image") {237const v = argv[++i];238if (!v) throw new Error("Missing value for --image");239out.imagePath = v;240continue;241}242243if (a === "--batchfile") {244const v = argv[++i];245if (!v) throw new Error("Missing value for --batchfile");246out.batchFile = v;247continue;248}249250if (a === "--jobs") {251const v = argv[++i];252if (!v) throw new Error("Missing value for --jobs");253out.jobs = parseInt(v, 10);254if (isNaN(out.jobs) || out.jobs < 1) throw new Error(`Invalid worker count: ${v}`);255continue;256}257258if (a === "--provider") {259const v = argv[++i];260if (261v !== "google" &&262v !== "openai" &&263v !== "openrouter" &&264v !== "dashscope" &&265v !== "zai" &&266v !== "minimax" &&267v !== "replicate" &&268v !== "jimeng" &&269v !== "seedream" &&270v !== "azure" &&271v !== "codex-cli" &&272v !== "agnes"273) {274throw new Error(`Invalid provider: ${v}`);275}276out.provider = v;277continue;278}279280if (a === "--model" || a === "-m") {281const v = argv[++i];282if (!v) throw new Error(`Missing value for ${a}`);283out.model = v;284continue;285}286287if (a === "--ar") {288const v = argv[++i];289if (!v) throw new Error("Missing value for --ar");290out.aspectRatio = v;291out.aspectRatioSource = "cli";292continue;293}294295if (a === "--size") {296const v = argv[++i];297if (!v) throw new Error("Missing value for --size");298out.size = v;299continue;300}301302if (a === "--quality") {303const v = argv[++i];304if (v !== "normal" && v !== "2k") throw new Error(`Invalid quality: ${v}`);305out.quality = v;306continue;307}308309if (a === "--imageSize") {310const v = argv[++i]?.toUpperCase();311if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);312out.imageSize = v;313out.imageSizeSource = "cli";314continue;315}316317if (a === "--imageApiDialect") {318const v = argv[++i];319if (v !== "openai-native" && v !== "ratio-metadata") {320throw new Error(`Invalid imageApiDialect: ${v}`);321}322out.imageApiDialect = v;323continue;324}325326if (a === "--response-format") {327const v = argv[++i];328if (v !== "file" && v !== "url") throw new Error(`Invalid response-format: ${v}`);329out.responseFormat = v;330continue;331}332333if (a === "--ref" || a === "--reference") {334const { items, next } = takeMany(i);335if (items.length === 0) throw new Error(`Missing files for ${a}`);336out.referenceImages.push(...items);337i = next;338continue;339}340341if (a === "--n") {342const v = argv[++i];343if (!v) throw new Error("Missing value for --n");344out.n = parseInt(v, 10);345if (isNaN(out.n) || out.n < 1) throw new Error(`Invalid count: ${v}`);346continue;347}348349if (a.startsWith("-")) {350throw new Error(`Unknown option: ${a}`);351}352353positional.push(a);354}355356if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {357out.prompt = positional.join(" ");358}359360return out;361}362363async function loadEnvFile(p: string): Promise<Record<string, string>> {364try {365const content = await readFile(p, "utf8");366const env: Record<string, string> = {};367for (const line of content.split("\n")) {368const trimmed = line.trim();369if (!trimmed || trimmed.startsWith("#")) continue;370const idx = trimmed.indexOf("=");371if (idx === -1) continue;372const key = trimmed.slice(0, idx).trim();373let val = trimmed.slice(idx + 1).trim();374if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {375val = val.slice(1, -1);376}377env[key] = val;378}379return env;380} catch {381return {};382}383}384385async function loadEnv(): Promise<void> {386const home = homedir();387const cwd = process.cwd();388389const homeEnv = await loadEnvFile(path.join(home, ".baoyu-skills", ".env"));390const cwdEnv = await loadEnvFile(path.join(cwd, ".baoyu-skills", ".env"));391392for (const [k, v] of Object.entries(homeEnv)) {393if (!process.env[k]) process.env[k] = v;394}395for (const [k, v] of Object.entries(cwdEnv)) {396if (!process.env[k]) process.env[k] = v;397}398}399400export function extractYamlFrontMatter(content: string): string | null {401const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);402return match ? match[1] : null;403}404405export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {406const config: Partial<ExtendConfig> = {};407const lines = yaml.split("\n");408let currentKey: string | null = null;409let currentProvider: Provider | null = null;410411for (const line of lines) {412const trimmed = line.trim();413const indent = line.match(/^\s*/)?.[0].length ?? 0;414if (!trimmed || trimmed.startsWith("#")) continue;415416if (trimmed.includes(":") && !trimmed.startsWith("-")) {417const colonIdx = trimmed.indexOf(":");418const key = trimmed.slice(0, colonIdx).trim();419let value = trimmed.slice(colonIdx + 1).trim();420421if (value === "null" || value === "") {422value = "null";423}424425if (key === "version") {426config.version = value === "null" ? 1 : parseInt(value, 10);427} else if (key === "default_provider") {428config.default_provider = value === "null" ? null : (value as Provider);429} else if (key === "default_quality") {430config.default_quality = value === "null" ? null : value as "normal" | "2k";431} else if (key === "default_aspect_ratio") {432const cleaned = value.replace(/['"]/g, "");433config.default_aspect_ratio = cleaned === "null" ? null : cleaned;434} else if (key === "default_image_size") {435config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";436} else if (key === "default_image_api_dialect") {437config.default_image_api_dialect =438value === "null" ? null : parseOpenAIImageApiDialect(value);439} else if (key === "default_model") {440config.default_model = {441google: null,442openai: null,443openrouter: null,444dashscope: null,445zai: null,446minimax: null,447replicate: null,448jimeng: null,449seedream: null,450azure: null,451"codex-cli": null,452agnes: null,453};454currentKey = "default_model";455currentProvider = null;456} else if (key === "batch") {457config.batch = {};458currentKey = "batch";459currentProvider = null;460} else if (currentKey === "batch" && indent >= 2 && key === "max_workers") {461config.batch ??= {};462config.batch.max_workers = value === "null" ? null : parseInt(value, 10);463} else if (currentKey === "batch" && indent >= 2 && key === "provider_limits") {464config.batch ??= {};465config.batch.provider_limits ??= {};466currentKey = "provider_limits";467currentProvider = null;468} else if (469currentKey === "provider_limits" &&470indent >= 4 &&471(472key === "google" ||473key === "openai" ||474key === "openrouter" ||475key === "dashscope" ||476key === "zai" ||477key === "minimax" ||478key === "replicate" ||479key === "jimeng" ||480key === "seedream" ||481key === "azure" ||482key === "codex-cli" ||483key === "agnes"484)485) {486config.batch ??= {};487config.batch.provider_limits ??= {};488config.batch.provider_limits[key] ??= {};489currentProvider = key;490} else if (491currentKey === "default_model" &&492(493key === "google" ||494key === "openai" ||495key === "openrouter" ||496key === "dashscope" ||497key === "zai" ||498key === "minimax" ||499key === "replicate" ||500key === "jimeng" ||501key === "seedream" ||502key === "azure" ||503key === "codex-cli" ||504key === "agnes"505)506) {507const cleaned = value.replace(/['"]/g, "");508config.default_model![key] = cleaned === "null" ? null : cleaned;509} else if (510currentKey === "provider_limits" &&511currentProvider &&512indent >= 6 &&513(key === "concurrency" || key === "start_interval_ms")514) {515config.batch ??= {};516config.batch.provider_limits ??= {};517const providerLimit = (config.batch.provider_limits[currentProvider] ??= {});518if (key === "concurrency") {519providerLimit.concurrency = value === "null" ? null : parseInt(value, 10);520} else {521providerLimit.start_interval_ms = value === "null" ? null : parseInt(value, 10);522}523}524}525}526527return config;528}529530export function parseOpenAIImageApiDialect(531value: string | undefined | null532): OpenAIImageApiDialect | null {533if (!value) return null;534const normalized = value.replace(/['"]/g, "").trim();535if (normalized === "openai-native" || normalized === "ratio-metadata") return normalized;536throw new Error(`Invalid OpenAI image API dialect: ${value}`);537}538539type ExtendConfigPathPair = {540current: string;541legacy: string;542};543544function getExtendConfigPathPairs(cwd: string, home: string): ExtendConfigPathPair[] {545return [546{547current: path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),548legacy: path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),549},550{551current: path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),552legacy: path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),553},554];555}556557async function exists(filePath: string): Promise<boolean> {558try {559await access(filePath);560return true;561} catch {562return false;563}564}565566export async function ensureDir(dir: string): Promise<void> {567try {568await mkdir(dir, { recursive: true });569} catch (err) {570// Bun on Windows incorrectly throws EEXIST for mkdir(dir, { recursive: true })571// when the directory already exists, contradicting Node's documented contract572// (mkdir with recursive: true resolves silently for an existing directory).573// Tolerate EEXIST only when the path really is a directory; rethrow otherwise574// (e.g. EEXIST raised because the path points at an existing file).575if ((err as { code?: string }).code !== "EEXIST") throw err;576if (!(await stat(dir)).isDirectory()) throw err;577}578}579580async function migrateLegacyExtendConfig(cwd: string, home: string): Promise<void> {581for (const { current, legacy } of getExtendConfigPathPairs(cwd, home)) {582const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]);583if (hasCurrent || !hasLegacy) continue;584await ensureDir(path.dirname(current));585await rename(legacy, current);586}587}588589export async function loadExtendConfig(590cwd = process.cwd(),591home = homedir(),592): Promise<Partial<ExtendConfig>> {593await migrateLegacyExtendConfig(cwd, home);594595const paths = getExtendConfigPathPairs(cwd, home).map(({ current }) => current);596597for (const p of paths) {598try {599const content = await readFile(p, "utf8");600const yaml = extractYamlFrontMatter(content);601if (!yaml) continue;602return parseSimpleYaml(yaml);603} catch {604continue;605}606}607608return {};609}610611export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {612const aspectRatio = args.aspectRatio ?? extend.default_aspect_ratio ?? null;613const imageSize = args.imageSize ?? extend.default_image_size ?? null;614const imageApiDialect =615args.imageApiDialect ??616extend.default_image_api_dialect ??617parseOpenAIImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT);618return {619...args,620provider: args.provider ?? extend.default_provider ?? null,621quality: args.quality ?? extend.default_quality ?? null,622aspectRatio,623aspectRatioSource:624args.aspectRatioSource ??625(args.aspectRatio !== null ? "cli" : (aspectRatio !== null ? "config" : null)),626imageSize,627imageSizeSource:628args.imageSizeSource ??629(args.imageSize !== null ? "cli" : (imageSize !== null ? "config" : null)),630imageApiDialect,631};632}633634export function parsePositiveInt(value: string | undefined): number | null {635if (!value) return null;636const parsed = parseInt(value, 10);637return Number.isFinite(parsed) && parsed > 0 ? parsed : null;638}639640export function parsePositiveBatchInt(value: unknown): number | null {641if (value === null || value === undefined) return null;642if (typeof value === "number") {643return Number.isInteger(value) && value > 0 ? value : null;644}645if (typeof value === "string") {646return parsePositiveInt(value);647}648return null;649}650651export function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {652const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS);653const configValue = extendConfig.batch?.max_workers ?? null;654return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS);655}656657export function getConfiguredProviderRateLimits(658extendConfig: Partial<ExtendConfig>659): Record<Provider, ProviderRateLimit> {660const configured: Record<Provider, ProviderRateLimit> = {661replicate: { ...DEFAULT_PROVIDER_RATE_LIMITS.replicate },662google: { ...DEFAULT_PROVIDER_RATE_LIMITS.google },663openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },664openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },665dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },666zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },667minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },668jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },669seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },670azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },671"codex-cli": { ...DEFAULT_PROVIDER_RATE_LIMITS["codex-cli"] },672agnes: { ...DEFAULT_PROVIDER_RATE_LIMITS.agnes },673};674675for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli", "agnes"] as Provider[]) {676const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase().replace(/-/g, "_")}`;677const extendLimit = extendConfig.batch?.provider_limits?.[provider];678configured[provider] = {679concurrency:680parsePositiveInt(process.env[`${envPrefix}_CONCURRENCY`]) ??681extendLimit?.concurrency ??682configured[provider].concurrency,683startIntervalMs:684parsePositiveInt(process.env[`${envPrefix}_START_INTERVAL_MS`]) ??685extendLimit?.start_interval_ms ??686configured[provider].startIntervalMs,687};688}689690return configured;691}692693async function readPromptFromFiles(files: string[]): Promise<string> {694const parts: string[] = [];695for (const f of files) {696parts.push(await readFile(f, "utf8"));697}698return parts.join("\n\n");699}700701async function readPromptFromStdin(): Promise<string | null> {702if (process.stdin.isTTY) return null;703try {704const chunks: Buffer[] = [];705for await (const chunk of process.stdin) {706chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));707}708const value = Buffer.concat(chunks).toString("utf8").trim();709return value.length > 0 ? value : null;710} catch {711return null;712}713}714715export function normalizeOutputImagePath(p: string, defaultExtension = ".png"): string {716const full = path.resolve(p);717const ext = path.extname(full);718if (ext) return full;719return `${full}${defaultExtension}`;720}721722function inferProviderFromModel(model: string | null): Provider | null {723if (!model) return null;724const normalized = model.trim();725if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";726if (normalized === "image-01" || normalized === "image-01-live") return "minimax";727if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai";728if (normalized.includes("agnes-image")) return "agnes";729return null;730}731732export function detectProvider(args: CliArgs): Provider {733if (734args.referenceImages.length > 0 &&735args.provider &&736args.provider !== "google" &&737args.provider !== "openai" &&738args.provider !== "azure" &&739args.provider !== "openrouter" &&740args.provider !== "replicate" &&741args.provider !== "seedream" &&742args.provider !== "minimax" &&743args.provider !== "dashscope" &&744args.provider !== "codex-cli" &&745args.provider !== "agnes"746) {747throw new Error(748"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, --provider minimax for MiniMax subject-reference workflows, --provider codex-cli (Codex image_gen with references), or --provider agnes (Agnes Image)."749);750}751752if (args.provider) return args.provider;753754const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);755const hasAzure = !!(process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_OPENAI_BASE_URL);756const hasOpenai = !!process.env.OPENAI_API_KEY;757const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;758const hasDashscope = !!process.env.DASHSCOPE_API_KEY;759const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);760const hasMinimax = !!process.env.MINIMAX_API_KEY;761const hasReplicate = !!process.env.REPLICATE_API_TOKEN;762const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);763const hasSeedream = !!process.env.ARK_API_KEY;764const hasAgnes = !!process.env.AGNES_API_KEY;765const modelProvider = inferProviderFromModel(args.model);766767if (modelProvider === "seedream") {768if (!hasSeedream) {769throw new Error("Model looks like a Volcengine ARK image model, but ARK_API_KEY is not set.");770}771return "seedream";772}773774if (modelProvider === "minimax") {775if (!hasMinimax) {776throw new Error("Model looks like a MiniMax image model, but MINIMAX_API_KEY is not set.");777}778return "minimax";779}780781if (modelProvider === "zai") {782if (!hasZai) {783throw new Error("Model looks like a Z.AI image model, but ZAI_API_KEY is not set.");784}785return "zai";786}787788if (modelProvider === "agnes") {789if (!hasAgnes) {790throw new Error("Model looks like an Agnes image model, but AGNES_API_KEY is not set.");791}792return "agnes";793}794795if (args.referenceImages.length > 0) {796if (hasGoogle) return "google";797if (hasOpenai) return "openai";798if (hasAzure) return "azure";799if (hasOpenrouter) return "openrouter";800if (hasReplicate) return "replicate";801if (hasSeedream) return "seedream";802if (hasMinimax) return "minimax";803if (hasAgnes) return "agnes";804throw new Error(805"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, MiniMax, or Agnes. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, MINIMAX_API_KEY, or AGNES_API_KEY, or remove --ref."806);807}808809const available = [810hasGoogle && "google",811hasOpenai && "openai",812hasAzure && "azure",813hasOpenrouter && "openrouter",814hasDashscope && "dashscope",815hasZai && "zai",816hasMinimax && "minimax",817hasReplicate && "replicate",818hasJimeng && "jimeng",819hasSeedream && "seedream",820hasAgnes && "agnes",821].filter(Boolean) as Provider[];822823if (available.length === 1) return available[0]!;824if (available.length > 1) return available[0]!;825826throw new Error(827"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, ARK_API_KEY, or AGNES_API_KEY.\n" +828"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."829);830}831832export type ReferenceImageValidationOptions = {833allowRemoteUrls?: boolean;834};835836function isRemoteReferenceImage(refPath: string): boolean {837return /^https?:\/\//i.test(refPath);838}839840function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean {841return provider === "dashscope" || provider === "agnes";842}843844export async function validateReferenceImages(845referenceImages: string[],846options: ReferenceImageValidationOptions = {},847): Promise<void> {848for (const refPath of referenceImages) {849if (options.allowRemoteUrls && isRemoteReferenceImage(refPath)) continue;850const fullPath = path.resolve(refPath);851try {852await access(fullPath);853} catch {854throw new Error(`Reference image not found: ${fullPath}`);855}856}857}858859export function isRetryableGenerationError(error: unknown): boolean {860const msg = error instanceof Error ? error.message : String(error);861const nonRetryableMarkers = [862"Reference image",863"not supported",864"only supported",865"No API key found",866"is required",867"Invalid ",868"Unexpected ",869"API error (400)",870"API error (401)",871"API error (402)",872"API error (403)",873"API error (404)",874"temporarily disabled",875"supports saving exactly one image",876"supports only",877"support exactly one output image",878"support aspect ratios in",879"requires total pixels between",880"accept at most",881];882return !nonRetryableMarkers.some((marker) => msg.includes(marker));883}884885async function loadProviderModule(provider: Provider): Promise<ProviderModule> {886if (provider === "google") return (await import("./providers/google")) as ProviderModule;887if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;888if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;889if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;890if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;891if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;892if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;893if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;894if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;895if (provider === "codex-cli") return (await import("./providers/codex-cli")) as ProviderModule;896if (provider === "agnes") return (await import("./providers/agnes")) as ProviderModule;897return (await import("./providers/openai")) as ProviderModule;898}899900async function loadPromptForArgs(args: CliArgs): Promise<string | null> {901let prompt: string | null = args.prompt;902if (!prompt && args.promptFiles.length > 0) {903prompt = await readPromptFromFiles(args.promptFiles);904}905return prompt;906}907908function getModelForProvider(909provider: Provider,910requestedModel: string | null,911extendConfig: Partial<ExtendConfig>,912providerModule: ProviderModule913): string {914if (requestedModel) return requestedModel;915if (extendConfig.default_model) {916if (provider === "google" && extendConfig.default_model.google) return extendConfig.default_model.google;917if (provider === "openai" && extendConfig.default_model.openai) return extendConfig.default_model.openai;918if (provider === "openrouter" && extendConfig.default_model.openrouter) {919return extendConfig.default_model.openrouter;920}921if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;922if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;923if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;924if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;925if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;926if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;927if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;928if (provider === "codex-cli" && extendConfig.default_model["codex-cli"]) return extendConfig.default_model["codex-cli"];929if (provider === "agnes" && extendConfig.default_model.agnes) return extendConfig.default_model.agnes;930}931return providerModule.getDefaultModel();932}933934async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<PreparedTask> {935if (!args.quality) args.quality = "2k";936937const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());938if (!prompt) throw new Error("Prompt is required");939if (!args.imagePath) throw new Error("--image is required");940if (args.referenceImages.length > 0) {941await validateReferenceImages(args.referenceImages, {942allowRemoteUrls: shouldAllowRemoteReferenceImages(args.provider),943});944}945946const provider = detectProvider(args);947const providerModule = await loadProviderModule(provider);948const model = getModelForProvider(provider, args.model, extendConfig, providerModule);949providerModule.validateArgs?.(model, args);950const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, args) ?? ".png";951952return {953id: "single",954prompt,955args,956provider,957model,958outputPath: normalizeOutputImagePath(args.imagePath, defaultOutputExtension),959providerModule,960};961}962963export async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {964const resolvedBatchFilePath = path.resolve(batchFilePath);965const content = await readFile(resolvedBatchFilePath, "utf8");966const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;967const batchDir = path.dirname(resolvedBatchFilePath);968if (Array.isArray(parsed)) {969return {970tasks: parsed,971jobs: null,972batchDir,973};974}975if (parsed && typeof parsed === "object" && Array.isArray(parsed.tasks)) {976const jobs = parsePositiveBatchInt(parsed.jobs);977if (parsed.jobs !== undefined && parsed.jobs !== null && jobs === null) {978throw new Error("Invalid batch file. jobs must be a positive integer when provided.");979}980return {981tasks: parsed.tasks,982jobs,983batchDir,984};985}986throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array.");987}988989export function resolveBatchPath(batchDir: string, filePath: string): string {990return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);991}992993function resolveBatchReferencePath(batchDir: string, filePath: string): string {994return isRemoteReferenceImage(filePath) ? filePath : resolveBatchPath(batchDir, filePath);995}996997export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {998return {999...baseArgs,1000prompt: task.prompt ?? null,1001promptFiles: task.promptFiles ? task.promptFiles.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],1002imagePath: task.image ? resolveBatchPath(batchDir, task.image) : null,1003provider: task.provider ?? baseArgs.provider ?? null,1004model: task.model ?? baseArgs.model ?? null,1005aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,1006aspectRatioSource: task.ar != null ? "task" : (baseArgs.aspectRatioSource ?? null),1007size: task.size ?? baseArgs.size ?? null,1008quality: task.quality ?? baseArgs.quality ?? null,1009imageSize: task.imageSize ?? baseArgs.imageSize ?? null,1010imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),1011imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,1012responseFormat: task.responseFormat ?? baseArgs.responseFormat ?? null,1013referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [],1014n: task.n ?? baseArgs.n,1015batchFile: null,1016jobs: baseArgs.jobs,1017json: baseArgs.json,1018help: false,1019};1020}10211022async function prepareBatchTasks(1023args: CliArgs,1024extendConfig: Partial<ExtendConfig>1025): Promise<{ tasks: PreparedTask[]; jobs: number | null }> {1026if (!args.batchFile) throw new Error("--batchfile is required in batch mode");1027const { tasks: taskInputs, jobs: batchJobs, batchDir } = await loadBatchTasks(args.batchFile);1028if (taskInputs.length === 0) throw new Error("Batch file does not contain any tasks.");10291030const prepared: PreparedTask[] = [];1031for (let i = 0; i < taskInputs.length; i++) {1032const task = taskInputs[i]!;1033const taskArgs = createTaskArgs(args, task, batchDir);1034const prompt = await loadPromptForArgs(taskArgs);1035if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`);1036if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`);1037if (taskArgs.referenceImages.length > 0) {1038await validateReferenceImages(taskArgs.referenceImages, {1039allowRemoteUrls: shouldAllowRemoteReferenceImages(taskArgs.provider),1040});1041}10421043const provider = detectProvider(taskArgs);1044const providerModule = await loadProviderModule(provider);1045const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);1046providerModule.validateArgs?.(model, taskArgs);1047const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, taskArgs) ?? ".png";1048prepared.push({1049id: task.id || `task-${String(i + 1).padStart(2, "0")}`,1050prompt,1051args: taskArgs,1052provider,1053model,1054outputPath: normalizeOutputImagePath(taskArgs.imagePath, defaultOutputExtension),1055providerModule,1056});1057}10581059return {1060tasks: prepared,1061jobs: args.jobs ?? batchJobs,1062};1063}10641065async function writeImage(outputPath: string, imageData: Uint8Array): Promise<void> {1066await ensureDir(path.dirname(outputPath));1067await writeFile(outputPath, imageData);1068}10691070async function generatePreparedTask(task: PreparedTask): Promise<TaskResult> {1071console.error(`Using ${task.provider} / ${task.model} for ${task.id}`);1072console.error(1073`Switch model: --model <id> | EXTEND.md default_model.${task.provider} | env ${task.provider.toUpperCase()}_IMAGE_MODEL`1074);10751076let attempts = 0;1077while (attempts < MAX_ATTEMPTS) {1078attempts += 1;1079try {1080const imageData = await task.providerModule.generateImage(task.prompt, task.model, task.args);1081await writeImage(task.outputPath, imageData);1082return {1083id: task.id,1084provider: task.provider,1085model: task.model,1086outputPath: task.outputPath,1087success: true,1088attempts,1089error: null,1090};1091} catch (error) {1092const message = error instanceof Error ? error.message : String(error);1093const canRetry = attempts < MAX_ATTEMPTS && isRetryableGenerationError(error);1094if (canRetry) {1095console.error(`[${task.id}] Attempt ${attempts}/${MAX_ATTEMPTS} failed, retrying...`);1096continue;1097}1098return {1099id: task.id,1100provider: task.provider,1101model: task.model,1102outputPath: task.outputPath,1103success: false,1104attempts,1105error: message,1106};1107}1108}11091110return {1111id: task.id,1112provider: task.provider,1113model: task.model,1114outputPath: task.outputPath,1115success: false,1116attempts: MAX_ATTEMPTS,1117error: "Unknown failure",1118};1119}11201121function createProviderGate(providerRateLimits: Record<Provider, ProviderRateLimit>) {1122const state = new Map<Provider, { active: number; lastStartedAt: number }>();11231124return async function acquire(provider: Provider): Promise<() => void> {1125const limit = providerRateLimits[provider];1126while (true) {1127const current = state.get(provider) ?? { active: 0, lastStartedAt: 0 };1128const now = Date.now();1129const enoughCapacity = current.active < limit.concurrency;1130const enoughGap = now - current.lastStartedAt >= limit.startIntervalMs;1131if (enoughCapacity && enoughGap) {1132state.set(provider, { active: current.active + 1, lastStartedAt: now });1133return () => {1134const latest = state.get(provider) ?? { active: 1, lastStartedAt: now };1135state.set(provider, {1136active: Math.max(0, latest.active - 1),1137lastStartedAt: latest.lastStartedAt,1138});1139};1140}1141await new Promise((resolve) => setTimeout(resolve, POLL_WAIT_MS));1142}1143};1144}11451146export function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {1147const requested = jobs ?? Math.min(taskCount, maxWorkers);1148return Math.max(1, Math.min(requested, taskCount, maxWorkers));1149}11501151async function runBatchTasks(1152tasks: PreparedTask[],1153jobs: number | null,1154extendConfig: Partial<ExtendConfig>1155): Promise<TaskResult[]> {1156if (tasks.length === 1) {1157return [await generatePreparedTask(tasks[0]!)];1158}11591160const maxWorkers = getConfiguredMaxWorkers(extendConfig);1161const providerRateLimits = getConfiguredProviderRateLimits(extendConfig);1162const acquireProvider = createProviderGate(providerRateLimits);1163const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);1164console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);1165for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli", "agnes"] as Provider[]) {1166const limit = providerRateLimits[provider];1167console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);1168}11691170let nextIndex = 0;1171const results: TaskResult[] = new Array(tasks.length);11721173const worker = async (): Promise<void> => {1174while (true) {1175const currentIndex = nextIndex;1176nextIndex += 1;1177if (currentIndex >= tasks.length) return;11781179const task = tasks[currentIndex]!;1180const release = await acquireProvider(task.provider);1181try {1182results[currentIndex] = await generatePreparedTask(task);1183} finally {1184release();1185}1186}1187};11881189await Promise.all(Array.from({ length: workerCount }, () => worker()));1190return results;1191}11921193function printBatchSummary(results: TaskResult[]): void {1194const successCount = results.filter((result) => result.success).length;1195const failureCount = results.length - successCount;11961197console.error("");1198console.error("Batch generation summary:");1199console.error(`- Total: ${results.length}`);1200console.error(`- Succeeded: ${successCount}`);1201console.error(`- Failed: ${failureCount}`);12021203if (failureCount > 0) {1204console.error("Failure reasons:");1205for (const result of results.filter((item) => !item.success)) {1206console.error(`- ${result.id}: ${result.error}`);1207}1208}1209}12101211function emitJson(payload: unknown): void {1212console.log(JSON.stringify(payload, null, 2));1213}12141215async function runSingleMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {1216const task = await prepareSingleTask(args, extendConfig);1217const result = await generatePreparedTask(task);1218if (!result.success) {1219throw new Error(result.error || "Generation failed");1220}12211222if (args.json) {1223emitJson({1224savedImage: result.outputPath,1225provider: result.provider,1226model: result.model,1227attempts: result.attempts,1228prompt: task.prompt.slice(0, 200),1229});1230return;1231}12321233console.log(result.outputPath);1234}12351236async function runBatchMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {1237const { tasks, jobs } = await prepareBatchTasks(args, extendConfig);1238const results = await runBatchTasks(tasks, jobs, extendConfig);1239printBatchSummary(results);12401241if (args.json) {1242emitJson({1243mode: "batch",1244total: results.length,1245succeeded: results.filter((item) => item.success).length,1246failed: results.filter((item) => !item.success).length,1247results,1248});1249}12501251if (results.some((item) => !item.success)) {1252process.exitCode = 1;1253}1254}12551256async function main(): Promise<void> {1257const args = parseArgs(process.argv.slice(2));1258if (args.help) {1259printUsage();1260return;1261}12621263await loadEnv();1264const extendConfig = await loadExtendConfig();1265const mergedArgs = mergeConfig(args, extendConfig);1266if (!mergedArgs.quality) mergedArgs.quality = "2k";12671268if (mergedArgs.batchFile) {1269await runBatchMode(mergedArgs, extendConfig);1270return;1271}12721273await runSingleMode(mergedArgs, extendConfig);1274}12751276function isDirectExecution(metaUrl: string): boolean {1277const entryPath = process.argv[1];1278if (!entryPath) return false;12791280try {1281return path.resolve(entryPath) === fileURLToPath(metaUrl);1282} catch {1283return false;1284}1285}12861287if (isDirectExecution(import.meta.url)) {1288main().catch((error) => {1289const message = error instanceof Error ? error.message : String(error);1290console.error(message);1291process.exit(1);1292});1293}1294