mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
feat\!: rename baoyu-imagine→baoyu-image-gen, baoyu-image-cards→baoyu-xhs-images (v2.0.0)
BREAKING CHANGE: removed `baoyu-imagine` and `baoyu-image-cards`. All functionality now lives under `baoyu-image-gen` and `baoyu-xhs-images` respectively. Cross-skill `## Image Generation Tools` examples updated across baoyu-article-illustrator, baoyu-comic, baoyu-cover-image, baoyu-infographic, and baoyu-slide-deck. Migration: existing `~/.baoyu-skills/baoyu-imagine/EXTEND.md` configs are auto-renamed to `…/baoyu-image-gen/EXTEND.md` on first run via the legacy-path resolver in `scripts/main.ts`.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import test from "node:test";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..", "..", "..");
|
||||
const scriptPath = path.join(repoRoot, "skills", "baoyu-image-gen", "scripts", "build-batch.ts");
|
||||
|
||||
async function makeFixture(): Promise<{
|
||||
root: string;
|
||||
outlinePath: string;
|
||||
promptsDir: string;
|
||||
outputPath: string;
|
||||
}> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "baoyu-image-gen-build-batch-"));
|
||||
const outlinePath = path.join(root, "outline.md");
|
||||
const promptsDir = path.join(root, "prompts");
|
||||
const outputPath = path.join(root, "batch.json");
|
||||
|
||||
await fs.mkdir(promptsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
outlinePath,
|
||||
`## Illustration 1
|
||||
**Position**: demo
|
||||
**Purpose**: demo
|
||||
**Visual Content**: demo
|
||||
**Filename**: 01-demo.png
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(path.join(promptsDir, "01-demo.md"), "A demo prompt\n");
|
||||
|
||||
return { root, outlinePath, promptsDir, outputPath };
|
||||
}
|
||||
|
||||
async function runBuildBatch(args: string[]): Promise<void> {
|
||||
await execFileAsync(process.execPath, ["--import", "tsx", scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
|
||||
test("build-batch omits default model so baoyu-image-gen can resolve env or EXTEND defaults", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.equal(batch.tasks.length, 1);
|
||||
assert.equal(batch.tasks[0]?.provider, "replicate");
|
||||
assert.equal(Object.hasOwn(batch.tasks[0]!, "model"), false);
|
||||
});
|
||||
|
||||
test("build-batch preserves explicit model overrides", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
"--model",
|
||||
"acme/custom-model",
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.equal(batch.tasks[0]?.model, "acme/custom-model");
|
||||
});
|
||||
|
||||
test("build-batch propagates direct-usage references from prompt frontmatter", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(fixture.promptsDir, "01-demo.md"),
|
||||
`---
|
||||
illustration_id: 01
|
||||
type: infographic
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: 01-ref-brand.png
|
||||
usage: direct
|
||||
- ref_id: 02
|
||||
filename: 02-ref-style.png
|
||||
usage: style
|
||||
---
|
||||
|
||||
A demo prompt
|
||||
`,
|
||||
);
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.deepEqual(batch.tasks[0]?.ref, ["references/01-ref-brand.png"]);
|
||||
});
|
||||
|
||||
test("build-batch omits ref field when no direct references exist", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(fixture.promptsDir, "01-demo.md"),
|
||||
`---
|
||||
illustration_id: 01
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: 01-ref-palette.png
|
||||
usage: palette
|
||||
---
|
||||
|
||||
A demo prompt
|
||||
`,
|
||||
);
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.equal(Object.hasOwn(batch.tasks[0]!, "ref"), false);
|
||||
});
|
||||
|
||||
test("build-batch honors --refs-dir override", async () => {
|
||||
const fixture = await makeFixture();
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(fixture.promptsDir, "01-demo.md"),
|
||||
`---
|
||||
illustration_id: 01
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: brand.png
|
||||
usage: direct
|
||||
---
|
||||
|
||||
A demo prompt
|
||||
`,
|
||||
);
|
||||
|
||||
await runBuildBatch([
|
||||
"--outline",
|
||||
fixture.outlinePath,
|
||||
"--prompts",
|
||||
fixture.promptsDir,
|
||||
"--output",
|
||||
fixture.outputPath,
|
||||
"--refs-dir",
|
||||
"refs",
|
||||
]);
|
||||
|
||||
const batch = JSON.parse(await fs.readFile(fixture.outputPath, "utf8")) as {
|
||||
tasks: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
assert.deepEqual(batch.tasks[0]?.ref, ["refs/brand.png"]);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
type CliArgs = {
|
||||
outlinePath: string | null;
|
||||
promptsDir: string | null;
|
||||
outputPath: string | null;
|
||||
imagesDir: string | null;
|
||||
refsDir: string;
|
||||
provider: string;
|
||||
model: string | null;
|
||||
aspectRatio: string;
|
||||
quality: string;
|
||||
jobs: number | null;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
type OutlineEntry = {
|
||||
index: number;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
type PromptReference = {
|
||||
filename: string;
|
||||
usage: "direct" | "style" | "palette";
|
||||
};
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage:
|
||||
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
|
||||
Options:
|
||||
--outline <path> Path to outline.md
|
||||
--prompts <path> Path to prompts directory
|
||||
--output <path> Path to output batch.json
|
||||
--images-dir <path> Directory for generated images
|
||||
--refs-dir <path> Directory holding reference images, relative to batch file (default: references)
|
||||
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
|
||||
--model <id> Explicit model for baoyu-image-gen batch tasks (default: resolved by baoyu-image-gen config/env)
|
||||
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
||||
--quality <level> Quality for all tasks (default: 2k)
|
||||
--jobs <count> Recommended worker count metadata (optional)
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
outlinePath: null,
|
||||
promptsDir: null,
|
||||
outputPath: null,
|
||||
imagesDir: null,
|
||||
refsDir: "references",
|
||||
provider: "replicate",
|
||||
model: null,
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
jobs: null,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const current = argv[i]!;
|
||||
if (current === "--outline") args.outlinePath = argv[++i] ?? null;
|
||||
else if (current === "--prompts") args.promptsDir = argv[++i] ?? null;
|
||||
else if (current === "--output") args.outputPath = argv[++i] ?? null;
|
||||
else if (current === "--images-dir") args.imagesDir = argv[++i] ?? null;
|
||||
else if (current === "--refs-dir") args.refsDir = argv[++i] ?? args.refsDir;
|
||||
else if (current === "--provider") args.provider = argv[++i] ?? args.provider;
|
||||
else if (current === "--model") args.model = argv[++i] ?? args.model;
|
||||
else if (current === "--ar") args.aspectRatio = argv[++i] ?? args.aspectRatio;
|
||||
else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
|
||||
else if (current === "--jobs") {
|
||||
const value = argv[++i];
|
||||
args.jobs = value ? parseInt(value, 10) : null;
|
||||
} else if (current === "--help" || current === "-h") {
|
||||
args.help = true;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function parsePromptReferences(content: string): PromptReference[] {
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
|
||||
if (!fmMatch) return [];
|
||||
const lines = fmMatch[1]!.split(/\r?\n/);
|
||||
|
||||
const refs: PromptReference[] = [];
|
||||
let current: Partial<PromptReference> | null = null;
|
||||
let inReferences = false;
|
||||
let listIndent = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (current?.filename) {
|
||||
refs.push({
|
||||
filename: current.filename,
|
||||
usage: (current.usage ?? "direct") as PromptReference["usage"],
|
||||
});
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
|
||||
const unquote = (raw: string): string => raw.trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||
|
||||
const keyMatch = line.match(/^(\S[^:]*):\s*(.*)$/);
|
||||
if (keyMatch) {
|
||||
flush();
|
||||
if (keyMatch[1] === "references") {
|
||||
inReferences = true;
|
||||
listIndent = 0;
|
||||
continue;
|
||||
}
|
||||
inReferences = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inReferences) continue;
|
||||
|
||||
const itemMatch = line.match(/^(\s*)-\s*(.*)$/);
|
||||
if (itemMatch) {
|
||||
flush();
|
||||
listIndent = itemMatch[1]!.length;
|
||||
current = {};
|
||||
const rest = itemMatch[2]!.trim();
|
||||
if (rest) {
|
||||
const kv = rest.match(/^(\w+)\s*:\s*(.*)$/);
|
||||
if (kv && (kv[1] === "filename" || kv[1] === "usage")) {
|
||||
(current as Record<string, string>)[kv[1]] = unquote(kv[2]!);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const kvMatch = line.match(/^(\s+)(\w+)\s*:\s*(.*)$/);
|
||||
if (kvMatch && kvMatch[1]!.length > listIndent && current) {
|
||||
if (kvMatch[2] === "filename" || kvMatch[2] === "usage") {
|
||||
(current as Record<string, string>)[kvMatch[2]!] = unquote(kvMatch[3]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function parseOutline(content: string): OutlineEntry[] {
|
||||
const entries: OutlineEntry[] = [];
|
||||
const blocks = content.split(/^## Illustration\s+/m).slice(1);
|
||||
|
||||
for (const block of blocks) {
|
||||
const indexMatch = block.match(/^(\d+)/);
|
||||
const filenameMatch = block.match(/\*\*Filename\*\*:\s*(.+)/);
|
||||
if (indexMatch && filenameMatch) {
|
||||
entries.push({
|
||||
index: parseInt(indexMatch[1]!, 10),
|
||||
filename: filenameMatch[1]!.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function findPromptFile(promptsDir: string, entry: OutlineEntry): Promise<string | null> {
|
||||
const files = await readdir(promptsDir);
|
||||
const prefix = String(entry.index).padStart(2, "0");
|
||||
const match = files.find((f) => f.startsWith(prefix) && f.endsWith(".md"));
|
||||
return match ? path.join(promptsDir, match) : null;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.outlinePath) {
|
||||
console.error("Error: --outline is required");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.promptsDir) {
|
||||
console.error("Error: --prompts is required");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.outputPath) {
|
||||
console.error("Error: --output is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outlineContent = await readFile(args.outlinePath, "utf8");
|
||||
const entries = parseOutline(outlineContent);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.error("No illustration entries found in outline.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
for (const entry of entries) {
|
||||
const promptFile = await findPromptFile(args.promptsDir, entry);
|
||||
if (!promptFile) {
|
||||
console.error(`Warning: No prompt file found for illustration ${entry.index}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
|
||||
const promptContent = await readFile(promptFile, "utf8");
|
||||
const refs = parsePromptReferences(promptContent)
|
||||
.filter((r) => r.usage === "direct")
|
||||
.map((r) => path.posix.join(args.refsDir, r.filename));
|
||||
|
||||
const task: Record<string, unknown> = {
|
||||
id: `illustration-${String(entry.index).padStart(2, "0")}`,
|
||||
promptFiles: [promptFile],
|
||||
image: path.join(imageDir, entry.filename),
|
||||
provider: args.provider,
|
||||
ar: args.aspectRatio,
|
||||
quality: args.quality,
|
||||
};
|
||||
if (args.model) task.model = args.model;
|
||||
if (refs.length > 0) task.ref = refs;
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = { tasks };
|
||||
if (args.jobs) output.jobs = args.jobs;
|
||||
|
||||
await writeFile(args.outputPath, JSON.stringify(output, null, 2) + "\n");
|
||||
console.log(`Batch file written: ${args.outputPath} (${tasks.length} tasks)`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -13,10 +13,13 @@ import {
|
||||
getWorkerCount,
|
||||
isRetryableGenerationError,
|
||||
loadBatchTasks,
|
||||
loadExtendConfig,
|
||||
mergeConfig,
|
||||
normalizeOutputImagePath,
|
||||
parseArgs,
|
||||
parseOpenAIImageApiDialect,
|
||||
parseSimpleYaml,
|
||||
validateReferenceImages,
|
||||
} from "./main.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
@@ -27,9 +30,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -69,7 +75,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test("parseArgs parses the main image-gen CLI flags", () => {
|
||||
test("parseArgs parses the main baoyu-image-gen CLI flags", () => {
|
||||
const args = parseArgs([
|
||||
"--promptfiles",
|
||||
"prompts/system.md",
|
||||
@@ -77,11 +83,13 @@ test("parseArgs parses the main image-gen CLI flags", () => {
|
||||
"--image",
|
||||
"out/hero",
|
||||
"--provider",
|
||||
"openai",
|
||||
"zai",
|
||||
"--quality",
|
||||
"2k",
|
||||
"--imageSize",
|
||||
"4k",
|
||||
"--imageApiDialect",
|
||||
"ratio-metadata",
|
||||
"--ref",
|
||||
"ref/one.png",
|
||||
"ref/two.jpg",
|
||||
@@ -94,9 +102,12 @@ test("parseArgs parses the main image-gen CLI flags", () => {
|
||||
|
||||
assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]);
|
||||
assert.equal(args.imagePath, "out/hero");
|
||||
assert.equal(args.provider, "openai");
|
||||
assert.equal(args.provider, "zai");
|
||||
assert.equal(args.quality, "2k");
|
||||
assert.equal(args.aspectRatioSource, null);
|
||||
assert.equal(args.imageSize, "4K");
|
||||
assert.equal(args.imageSizeSource, "cli");
|
||||
assert.equal(args.imageApiDialect, "ratio-metadata");
|
||||
assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]);
|
||||
assert.equal(args.n, 3);
|
||||
assert.equal(args.jobs, 5);
|
||||
@@ -113,6 +124,15 @@ test("parseArgs falls back to positional prompt and rejects invalid provider", (
|
||||
);
|
||||
});
|
||||
|
||||
test("validateReferenceImages can skip remote URLs for providers that support them", async () => {
|
||||
await validateReferenceImages(["https://example.com/ref.png"], { allowRemoteUrls: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => validateReferenceImages(["https://example.com/ref.png"]),
|
||||
/Reference image not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSimpleYaml parses nested defaults and provider limits", () => {
|
||||
const yaml = `
|
||||
version: 2
|
||||
@@ -120,9 +140,11 @@ default_provider: openrouter
|
||||
default_quality: normal
|
||||
default_aspect_ratio: '16:9'
|
||||
default_image_size: 2K
|
||||
default_image_api_dialect: ratio-metadata
|
||||
default_model:
|
||||
google: gemini-3-pro-image-preview
|
||||
openai: gpt-image-1.5
|
||||
openai: gpt-image-2
|
||||
zai: glm-image
|
||||
azure: image-prod
|
||||
minimax: image-01
|
||||
batch:
|
||||
@@ -133,6 +155,9 @@ batch:
|
||||
start_interval_ms: 900
|
||||
openai:
|
||||
concurrency: 4
|
||||
zai:
|
||||
concurrency: 2
|
||||
start_interval_ms: 1000
|
||||
minimax:
|
||||
concurrency: 2
|
||||
start_interval_ms: 1400
|
||||
@@ -148,8 +173,10 @@ batch:
|
||||
assert.equal(config.default_quality, "normal");
|
||||
assert.equal(config.default_aspect_ratio, "16:9");
|
||||
assert.equal(config.default_image_size, "2K");
|
||||
assert.equal(config.default_image_api_dialect, "ratio-metadata");
|
||||
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||
assert.equal(config.default_model?.openai, "gpt-image-2");
|
||||
assert.equal(config.default_model?.zai, "glm-image");
|
||||
assert.equal(config.default_model?.azure, "image-prod");
|
||||
assert.equal(config.default_model?.minimax, "image-01");
|
||||
assert.equal(config.batch?.max_workers, 8);
|
||||
@@ -160,6 +187,10 @@ batch:
|
||||
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||
concurrency: 4,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.zai, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 1000,
|
||||
});
|
||||
assert.deepEqual(config.batch?.provider_limits?.minimax, {
|
||||
concurrency: 2,
|
||||
start_interval_ms: 1400,
|
||||
@@ -170,6 +201,61 @@ batch:
|
||||
});
|
||||
});
|
||||
|
||||
test("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
|
||||
const root = await makeTempDir("baoyu-image-gen-extend-");
|
||||
const cwd = path.join(root, "project");
|
||||
const home = path.join(root, "home");
|
||||
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
|
||||
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
|
||||
|
||||
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.writeFile(legacyPath, `---
|
||||
default_provider: google
|
||||
default_quality: 2k
|
||||
---
|
||||
`);
|
||||
|
||||
const config = await loadExtendConfig(cwd, home);
|
||||
|
||||
assert.equal(config.default_provider, "google");
|
||||
assert.equal(config.default_quality, "2k");
|
||||
await fs.access(currentPath);
|
||||
await assert.rejects(() => fs.access(legacyPath));
|
||||
});
|
||||
|
||||
test("loadExtendConfig leaves legacy EXTEND.md untouched when both paths exist", async () => {
|
||||
const root = await makeTempDir("baoyu-image-gen-extend-dual-");
|
||||
const cwd = path.join(root, "project");
|
||||
const home = path.join(root, "home");
|
||||
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
|
||||
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
|
||||
|
||||
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(currentPath), { recursive: true });
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.writeFile(legacyPath, `---
|
||||
default_provider: google
|
||||
---
|
||||
`);
|
||||
await fs.writeFile(currentPath, `---
|
||||
default_provider: openai
|
||||
---
|
||||
`);
|
||||
|
||||
const config = await loadExtendConfig(cwd, home);
|
||||
|
||||
assert.equal(config.default_provider, "openai");
|
||||
assert.equal(await fs.readFile(legacyPath, "utf8"), `---
|
||||
default_provider: google
|
||||
---
|
||||
`);
|
||||
assert.equal(await fs.readFile(currentPath, "utf8"), `---
|
||||
default_provider: openai
|
||||
---
|
||||
`);
|
||||
});
|
||||
|
||||
test("mergeConfig only fills values missing from CLI args", () => {
|
||||
const merged = mergeConfig(
|
||||
makeArgs({
|
||||
@@ -183,13 +269,48 @@ test("mergeConfig only fills values missing from CLI args", () => {
|
||||
default_quality: "2k",
|
||||
default_aspect_ratio: "3:2",
|
||||
default_image_size: "2K",
|
||||
default_image_api_dialect: "ratio-metadata",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.provider, "openai");
|
||||
assert.equal(merged.quality, "2k");
|
||||
assert.equal(merged.aspectRatio, "3:2");
|
||||
assert.equal(merged.aspectRatioSource, "config");
|
||||
assert.equal(merged.imageSize, "4K");
|
||||
assert.equal(merged.imageSizeSource, "cli");
|
||||
assert.equal(merged.imageApiDialect, "ratio-metadata");
|
||||
});
|
||||
|
||||
test("mergeConfig tags inherited imageSize defaults so providers can ignore incompatible config", () => {
|
||||
const merged = mergeConfig(
|
||||
makeArgs(),
|
||||
{
|
||||
default_image_size: "2K",
|
||||
} satisfies Partial<ExtendConfig>,
|
||||
);
|
||||
|
||||
assert.equal(merged.imageSize, "2K");
|
||||
assert.equal(merged.imageSizeSource, "config");
|
||||
});
|
||||
|
||||
test("mergeConfig falls back to OPENAI_IMAGE_API_DIALECT when CLI and EXTEND are unset", (t) => {
|
||||
useEnv(t, {
|
||||
OPENAI_IMAGE_API_DIALECT: "ratio-metadata",
|
||||
});
|
||||
|
||||
const merged = mergeConfig(makeArgs(), {});
|
||||
assert.equal(merged.imageApiDialect, "ratio-metadata");
|
||||
});
|
||||
|
||||
test("parseOpenAIImageApiDialect validates supported values", () => {
|
||||
assert.equal(parseOpenAIImageApiDialect("openai-native"), "openai-native");
|
||||
assert.equal(parseOpenAIImageApiDialect("ratio-metadata"), "ratio-metadata");
|
||||
assert.equal(parseOpenAIImageApiDialect(null), null);
|
||||
assert.throws(
|
||||
() => parseOpenAIImageApiDialect("gateway-magic"),
|
||||
/Invalid OpenAI image API dialect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => {
|
||||
@@ -197,7 +318,7 @@ test("detectProvider rejects non-ref-capable providers and prefers Google first
|
||||
() =>
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
provider: "dashscope",
|
||||
provider: "zai",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
@@ -260,6 +381,27 @@ test("detectProvider selects Azure when only Azure credentials are configured",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider selects Z.AI when credentials are present or the model id matches", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_BASE_URL: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
ZAI_API_KEY: "zai-key",
|
||||
BIGMODEL_API_KEY: null,
|
||||
MINIMAX_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
|
||||
assert.equal(detectProvider(makeArgs()), "zai");
|
||||
assert.equal(detectProvider(makeArgs({ model: "glm-image" })), "zai");
|
||||
});
|
||||
|
||||
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
@@ -294,6 +436,33 @@ test("detectProvider infers Seedream from model id and allows Seedream reference
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider allows DashScope reference-image workflows when explicitly chosen for wan2.7 models", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_API_KEY: null,
|
||||
AZURE_OPENAI_BASE_URL: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: "dashscope-key",
|
||||
MINIMAX_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: null,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
provider: "dashscope",
|
||||
model: "wan2.7-image-pro",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
"dashscope",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
@@ -319,6 +488,7 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5",
|
||||
BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450",
|
||||
BAOYU_IMAGE_GEN_ZAI_CONCURRENCY: "4",
|
||||
});
|
||||
|
||||
const extendConfig: Partial<ExtendConfig> = {
|
||||
@@ -329,6 +499,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
concurrency: 2,
|
||||
start_interval_ms: 900,
|
||||
},
|
||||
zai: {
|
||||
concurrency: 1,
|
||||
start_interval_ms: 1200,
|
||||
},
|
||||
minimax: {
|
||||
concurrency: 1,
|
||||
start_interval_ms: 1500,
|
||||
@@ -342,6 +516,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
||||
concurrency: 5,
|
||||
startIntervalMs: 450,
|
||||
});
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).zai, {
|
||||
concurrency: 4,
|
||||
startIntervalMs: 1200,
|
||||
});
|
||||
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
|
||||
concurrency: 1,
|
||||
startIntervalMs: 1500,
|
||||
@@ -363,7 +541,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
id: "hero",
|
||||
promptFiles: ["prompts/hero.md"],
|
||||
image: "out/hero",
|
||||
ref: ["refs/hero.png"],
|
||||
ref: ["refs/hero.png", "https://example.com/ref.png"],
|
||||
ar: "16:9",
|
||||
},
|
||||
],
|
||||
@@ -379,6 +557,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
makeArgs({
|
||||
provider: "replicate",
|
||||
quality: "2k",
|
||||
imageApiDialect: "ratio-metadata",
|
||||
json: true,
|
||||
}),
|
||||
loaded.tasks[0]!,
|
||||
@@ -391,10 +570,12 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero"));
|
||||
assert.deepEqual(taskArgs.referenceImages, [
|
||||
path.join(loaded.batchDir, "refs/hero.png"),
|
||||
"https://example.com/ref.png",
|
||||
]);
|
||||
assert.equal(taskArgs.provider, "replicate");
|
||||
assert.equal(taskArgs.aspectRatio, "16:9");
|
||||
assert.equal(taskArgs.quality, "2k");
|
||||
assert.equal(taskArgs.imageApiDialect, "ratio-metadata");
|
||||
assert.equal(taskArgs.json, true);
|
||||
});
|
||||
|
||||
@@ -408,5 +589,35 @@ test("path normalization, worker count, and retry classification follow expected
|
||||
assert.equal(getWorkerCount(5, 0, 4), 1);
|
||||
|
||||
assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("Replicate returned 2 outputs, but baoyu-image-gen currently supports saving exactly one image per request."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("DashScope wan2.7 image models accept at most 9 reference images. Received 10."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("DashScope wan2.7 image models in baoyu-image-gen support exactly one output image per request."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRetryableGenerationError(
|
||||
new Error("DashScope wan2.7-image requires total pixels between 768*768 and 2048*2048."),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isRetryableGenerationError(new Error("socket hang up")), true);
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import type {
|
||||
BatchFile,
|
||||
BatchTaskInput,
|
||||
CliArgs,
|
||||
ExtendConfig,
|
||||
OpenAIImageApiDialect,
|
||||
Provider,
|
||||
} from "./types";
|
||||
|
||||
@@ -58,11 +59,11 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||
zai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
minimax: { concurrency: 3, startIntervalMs: 1100 },
|
||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||
azure: { concurrency: 3, startIntervalMs: 1100 },
|
||||
zai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
};
|
||||
|
||||
function printUsage(): void {
|
||||
@@ -77,14 +78,15 @@ Options:
|
||||
--image <path> Output image path (required in single-image mode)
|
||||
--batchfile <path> JSON batch file for multi-image generation
|
||||
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
||||
--provider google|openai|openrouter|dashscope|minimax|replicate|jimeng|seedream|azure|zai Force provider (auto-detect by default)
|
||||
--provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
|
||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1)
|
||||
--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
|
||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*)
|
||||
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
|
||||
@@ -97,7 +99,7 @@ Batch file format:
|
||||
"promptFiles": ["prompts/hero.md"],
|
||||
"image": "out/hero.png",
|
||||
"provider": "replicate",
|
||||
"model": "google/nano-banana-pro",
|
||||
"model": "google/nano-banana-2",
|
||||
"ar": "16:9"
|
||||
}
|
||||
]
|
||||
@@ -107,6 +109,7 @@ Behavior:
|
||||
- Batch mode automatically runs in parallel when pending tasks >= 2
|
||||
- Each image retries automatically up to 3 attempts
|
||||
- Batch summary reports success count, failure count, and per-image errors
|
||||
- Replicate currently supports single-image save semantics only; --n must stay at 1
|
||||
|
||||
Environment variables:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
@@ -114,30 +117,33 @@ Environment variables:
|
||||
GOOGLE_API_KEY Google API key
|
||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope API key
|
||||
ZAI_API_KEY Z.AI API key
|
||||
BIGMODEL_API_KEY Backward-compatible alias for Z.AI API key
|
||||
MINIMAX_API_KEY MiniMax API key
|
||||
REPLICATE_API_TOKEN Replicate API token
|
||||
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
||||
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
|
||||
ARK_API_KEY Seedream/Ark API key
|
||||
ZAI_API_KEY Z.AI API key (alias: BIGMODEL_API_KEY)
|
||||
BIGMODEL_API_KEY Z.AI API key alias (legacy BigModel credentials)
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-2)
|
||||
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
|
||||
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
|
||||
BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)
|
||||
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-2)
|
||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
|
||||
BIGMODEL_IMAGE_MODEL Z.AI model alias (legacy BigModel variable)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
OPENAI_IMAGE_API_DIALECT OpenAI-compatible image dialect (openai-native|ratio-metadata)
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
OPENROUTER_BASE_URL Custom OpenRouter endpoint
|
||||
OPENROUTER_HTTP_REFERER Optional app URL for OpenRouter attribution
|
||||
OPENROUTER_TITLE Optional app name for OpenRouter attribution
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
ZAI_BASE_URL Custom Z.AI endpoint
|
||||
BIGMODEL_BASE_URL Backward-compatible alias for Z.AI endpoint
|
||||
MINIMAX_BASE_URL Custom MiniMax endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||
@@ -145,10 +151,8 @@ Environment variables:
|
||||
AZURE_OPENAI_BASE_URL Azure OpenAI resource or deployment endpoint
|
||||
AZURE_OPENAI_DEPLOYMENT Default Azure deployment name
|
||||
AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)
|
||||
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-1.5)
|
||||
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2)
|
||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||
ZAI_BASE_URL Custom Z.AI endpoint (defaults to https://api.z.ai/api/paas/v4)
|
||||
BIGMODEL_BASE_URL Z.AI endpoint alias (legacy BigModel variable)
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
||||
@@ -164,9 +168,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -246,12 +253,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
v !== "openai" &&
|
||||
v !== "openrouter" &&
|
||||
v !== "dashscope" &&
|
||||
v !== "zai" &&
|
||||
v !== "minimax" &&
|
||||
v !== "replicate" &&
|
||||
v !== "jimeng" &&
|
||||
v !== "seedream" &&
|
||||
v !== "azure" &&
|
||||
v !== "zai"
|
||||
v !== "azure"
|
||||
) {
|
||||
throw new Error(`Invalid provider: ${v}`);
|
||||
}
|
||||
@@ -270,6 +277,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error("Missing value for --ar");
|
||||
out.aspectRatio = v;
|
||||
out.aspectRatioSource = "cli";
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -291,6 +299,16 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const v = argv[++i]?.toUpperCase();
|
||||
if (v !== "1K" && v !== "2K" && v !== "4K") throw new Error(`Invalid imageSize: ${v}`);
|
||||
out.imageSize = v;
|
||||
out.imageSizeSource = "cli";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--imageApiDialect") {
|
||||
const v = argv[++i];
|
||||
if (v !== "openai-native" && v !== "ratio-metadata") {
|
||||
throw new Error(`Invalid imageApiDialect: ${v}`);
|
||||
}
|
||||
out.imageApiDialect = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -397,18 +415,21 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||
} else if (key === "default_image_size") {
|
||||
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
|
||||
} else if (key === "default_image_api_dialect") {
|
||||
config.default_image_api_dialect =
|
||||
value === "null" ? null : parseOpenAIImageApiDialect(value);
|
||||
} else if (key === "default_model") {
|
||||
config.default_model = {
|
||||
google: null,
|
||||
openai: null,
|
||||
openrouter: null,
|
||||
dashscope: null,
|
||||
zai: null,
|
||||
minimax: null,
|
||||
replicate: null,
|
||||
jimeng: null,
|
||||
seedream: null,
|
||||
azure: null,
|
||||
zai: null,
|
||||
};
|
||||
currentKey = "default_model";
|
||||
currentProvider = null;
|
||||
@@ -432,12 +453,12 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "zai" ||
|
||||
key === "minimax" ||
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream" ||
|
||||
key === "azure" ||
|
||||
key === "zai"
|
||||
key === "azure"
|
||||
)
|
||||
) {
|
||||
config.batch ??= {};
|
||||
@@ -451,12 +472,12 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "zai" ||
|
||||
key === "minimax" ||
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream" ||
|
||||
key === "azure" ||
|
||||
key === "zai"
|
||||
key === "azure"
|
||||
)
|
||||
) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
@@ -482,14 +503,58 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
return config;
|
||||
}
|
||||
|
||||
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
export function parseOpenAIImageApiDialect(
|
||||
value: string | undefined | null
|
||||
): OpenAIImageApiDialect | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.replace(/['"]/g, "").trim();
|
||||
if (normalized === "openai-native" || normalized === "ratio-metadata") return normalized;
|
||||
throw new Error(`Invalid OpenAI image API dialect: ${value}`);
|
||||
}
|
||||
|
||||
const paths = [
|
||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
type ExtendConfigPathPair = {
|
||||
current: string;
|
||||
legacy: string;
|
||||
};
|
||||
|
||||
function getExtendConfigPathPairs(cwd: string, home: string): ExtendConfigPathPair[] {
|
||||
return [
|
||||
{
|
||||
current: path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
legacy: path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||
},
|
||||
{
|
||||
current: path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||
legacy: path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyExtendConfig(cwd: string, home: string): Promise<void> {
|
||||
for (const { current, legacy } of getExtendConfigPathPairs(cwd, home)) {
|
||||
const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]);
|
||||
if (hasCurrent || !hasLegacy) continue;
|
||||
await mkdir(path.dirname(current), { recursive: true });
|
||||
await rename(legacy, current);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadExtendConfig(
|
||||
cwd = process.cwd(),
|
||||
home = homedir(),
|
||||
): Promise<Partial<ExtendConfig>> {
|
||||
await migrateLegacyExtendConfig(cwd, home);
|
||||
|
||||
const paths = getExtendConfigPathPairs(cwd, home).map(({ current }) => current);
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
@@ -506,12 +571,25 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
||||
}
|
||||
|
||||
export function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
||||
const aspectRatio = args.aspectRatio ?? extend.default_aspect_ratio ?? null;
|
||||
const imageSize = args.imageSize ?? extend.default_image_size ?? null;
|
||||
const imageApiDialect =
|
||||
args.imageApiDialect ??
|
||||
extend.default_image_api_dialect ??
|
||||
parseOpenAIImageApiDialect(process.env.OPENAI_IMAGE_API_DIALECT);
|
||||
return {
|
||||
...args,
|
||||
provider: args.provider ?? extend.default_provider ?? null,
|
||||
quality: args.quality ?? extend.default_quality ?? null,
|
||||
aspectRatio: args.aspectRatio ?? extend.default_aspect_ratio ?? null,
|
||||
imageSize: args.imageSize ?? extend.default_image_size ?? null,
|
||||
aspectRatio,
|
||||
aspectRatioSource:
|
||||
args.aspectRatioSource ??
|
||||
(args.aspectRatio !== null ? "cli" : (aspectRatio !== null ? "config" : null)),
|
||||
imageSize,
|
||||
imageSizeSource:
|
||||
args.imageSizeSource ??
|
||||
(args.imageSize !== null ? "cli" : (imageSize !== null ? "config" : null)),
|
||||
imageApiDialect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -547,14 +625,14 @@ export function getConfiguredProviderRateLimits(
|
||||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||
zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },
|
||||
minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },
|
||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
||||
zai: { ...DEFAULT_PROVIDER_RATE_LIMITS.zai },
|
||||
};
|
||||
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "minimax", "jimeng", "seedream", "azure", "zai"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||
configured[provider] = {
|
||||
@@ -606,6 +684,7 @@ function inferProviderFromModel(model: string | null): Provider | null {
|
||||
const normalized = model.trim();
|
||||
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
|
||||
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
|
||||
if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -619,10 +698,11 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
args.provider !== "openrouter" &&
|
||||
args.provider !== "replicate" &&
|
||||
args.provider !== "seedream" &&
|
||||
args.provider !== "minimax"
|
||||
args.provider !== "minimax" &&
|
||||
args.provider !== "dashscope"
|
||||
) {
|
||||
throw new Error(
|
||||
"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 seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows."
|
||||
"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, or --provider minimax for MiniMax subject-reference workflows."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -633,11 +713,11 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||
const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);
|
||||
const hasMinimax = !!process.env.MINIMAX_API_KEY;
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||
const hasSeedream = !!process.env.ARK_API_KEY;
|
||||
const hasZai = !!(process.env.ZAI_API_KEY || process.env.BIGMODEL_API_KEY);
|
||||
const modelProvider = inferProviderFromModel(args.model);
|
||||
|
||||
if (modelProvider === "seedream") {
|
||||
@@ -654,6 +734,13 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
return "minimax";
|
||||
}
|
||||
|
||||
if (modelProvider === "zai") {
|
||||
if (!hasZai) {
|
||||
throw new Error("Model looks like a Z.AI image model, but ZAI_API_KEY is not set.");
|
||||
}
|
||||
return "zai";
|
||||
}
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
@@ -673,24 +760,40 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
hasAzure && "azure",
|
||||
hasOpenrouter && "openrouter",
|
||||
hasDashscope && "dashscope",
|
||||
hasZai && "zai",
|
||||
hasMinimax && "minimax",
|
||||
hasReplicate && "replicate",
|
||||
hasJimeng && "jimeng",
|
||||
hasSeedream && "seedream",
|
||||
hasZai && "zai",
|
||||
].filter(Boolean) as Provider[];
|
||||
|
||||
if (available.length === 1) return available[0]!;
|
||||
if (available.length > 1) return available[0]!;
|
||||
|
||||
throw new Error(
|
||||
"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, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, ARK_API_KEY, or ZAI_API_KEY/BIGMODEL_API_KEY.\n" +
|
||||
"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, or ARK_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateReferenceImages(referenceImages: string[]): Promise<void> {
|
||||
export type ReferenceImageValidationOptions = {
|
||||
allowRemoteUrls?: boolean;
|
||||
};
|
||||
|
||||
function isRemoteReferenceImage(refPath: string): boolean {
|
||||
return /^https?:\/\//i.test(refPath);
|
||||
}
|
||||
|
||||
function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean {
|
||||
return provider === "dashscope";
|
||||
}
|
||||
|
||||
export async function validateReferenceImages(
|
||||
referenceImages: string[],
|
||||
options: ReferenceImageValidationOptions = {},
|
||||
): Promise<void> {
|
||||
for (const refPath of referenceImages) {
|
||||
if (options.allowRemoteUrls && isRemoteReferenceImage(refPath)) continue;
|
||||
const fullPath = path.resolve(refPath);
|
||||
try {
|
||||
await access(fullPath);
|
||||
@@ -716,6 +819,12 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
||||
"API error (403)",
|
||||
"API error (404)",
|
||||
"temporarily disabled",
|
||||
"supports saving exactly one image",
|
||||
"supports only",
|
||||
"support exactly one output image",
|
||||
"support aspect ratios in",
|
||||
"requires total pixels between",
|
||||
"accept at most",
|
||||
];
|
||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||
}
|
||||
@@ -723,13 +832,13 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
||||
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||
if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;
|
||||
if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;
|
||||
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
||||
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
||||
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
||||
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
|
||||
if (provider === "zai") return (await import("./providers/zai")) as ProviderModule;
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
@@ -755,12 +864,12 @@ function getModelForProvider(
|
||||
return extendConfig.default_model.openrouter;
|
||||
}
|
||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||
if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;
|
||||
if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;
|
||||
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
||||
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
||||
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
|
||||
if (provider === "zai" && extendConfig.default_model.zai) return extendConfig.default_model.zai;
|
||||
}
|
||||
return providerModule.getDefaultModel();
|
||||
}
|
||||
@@ -771,7 +880,11 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());
|
||||
if (!prompt) throw new Error("Prompt is required");
|
||||
if (!args.imagePath) throw new Error("--image is required");
|
||||
if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);
|
||||
if (args.referenceImages.length > 0) {
|
||||
await validateReferenceImages(args.referenceImages, {
|
||||
allowRemoteUrls: shouldAllowRemoteReferenceImages(args.provider),
|
||||
});
|
||||
}
|
||||
|
||||
const provider = detectProvider(args);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
@@ -820,6 +933,10 @@ export function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
|
||||
}
|
||||
|
||||
function resolveBatchReferencePath(batchDir: string, filePath: string): string {
|
||||
return isRemoteReferenceImage(filePath) ? filePath : resolveBatchPath(batchDir, filePath);
|
||||
}
|
||||
|
||||
export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||
return {
|
||||
...baseArgs,
|
||||
@@ -829,10 +946,13 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir
|
||||
provider: task.provider ?? baseArgs.provider ?? null,
|
||||
model: task.model ?? baseArgs.model ?? null,
|
||||
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
|
||||
aspectRatioSource: task.ar != null ? "task" : (baseArgs.aspectRatioSource ?? null),
|
||||
size: task.size ?? baseArgs.size ?? null,
|
||||
quality: task.quality ?? baseArgs.quality ?? null,
|
||||
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
|
||||
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||
imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),
|
||||
imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,
|
||||
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [],
|
||||
n: task.n ?? baseArgs.n,
|
||||
batchFile: null,
|
||||
jobs: baseArgs.jobs,
|
||||
@@ -856,7 +976,11 @@ async function prepareBatchTasks(
|
||||
const prompt = await loadPromptForArgs(taskArgs);
|
||||
if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`);
|
||||
if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`);
|
||||
if (taskArgs.referenceImages.length > 0) await validateReferenceImages(taskArgs.referenceImages);
|
||||
if (taskArgs.referenceImages.length > 0) {
|
||||
await validateReferenceImages(taskArgs.referenceImages, {
|
||||
allowRemoteUrls: shouldAllowRemoteReferenceImages(taskArgs.provider),
|
||||
});
|
||||
}
|
||||
|
||||
const provider = detectProvider(taskArgs);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
@@ -980,7 +1104,7 @@ async function runBatchTasks(
|
||||
const acquireProvider = createProviderGate(providerRateLimits);
|
||||
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream", "azure", "zai"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||
const limit = providerRateLimits[provider];
|
||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -46,7 +46,7 @@ export function getDefaultModel(): string {
|
||||
}
|
||||
}
|
||||
|
||||
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-2";
|
||||
}
|
||||
|
||||
function getEndpoint(): AzureEndpoint {
|
||||
|
||||
@@ -2,15 +2,42 @@ import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
generateImage,
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
getQwen2SizeFromAspectRatio,
|
||||
getSizeFromAspectRatio,
|
||||
getWan27SizeFromAspectRatio,
|
||||
normalizeSize,
|
||||
parseAspectRatio,
|
||||
parseSize,
|
||||
resolveSizeForModel,
|
||||
} from "./dashscope.ts";
|
||||
import type { CliArgs } from "../types.ts";
|
||||
|
||||
function makeCliArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: "dashscope",
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
@@ -51,9 +78,11 @@ test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||
assert.equal(parseAspectRatio("-1:2"), null);
|
||||
});
|
||||
|
||||
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
|
||||
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, wan2.7, and legacy models", () => {
|
||||
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
|
||||
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
|
||||
assert.equal(getModelFamily("wan2.7-image"), "wan27");
|
||||
assert.equal(getModelFamily("wan2.7-image-pro"), "wan27");
|
||||
assert.equal(getModelFamily("z-image-turbo"), "legacy");
|
||||
assert.equal(getModelFamily("wanx-v1"), "legacy");
|
||||
});
|
||||
@@ -146,3 +175,218 @@ test("DashScope size normalization converts WxH into provider format", () => {
|
||||
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
||||
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
||||
});
|
||||
|
||||
test("Wan 2.7 derives sizes that match the requested ratio at the chosen pixel budget", () => {
|
||||
const square2k = getWan27SizeFromAspectRatio(null, "2k", 2048 * 2048);
|
||||
const parsedSquare = parseSize(square2k);
|
||||
assert.ok(parsedSquare);
|
||||
assert.equal(parsedSquare.width, parsedSquare.height);
|
||||
assert.ok(parsedSquare.width * parsedSquare.height <= 2048 * 2048);
|
||||
|
||||
const widescreen = getWan27SizeFromAspectRatio("16:9", "2k", 2048 * 2048);
|
||||
const parsedWide = parseSize(widescreen);
|
||||
assert.ok(parsedWide);
|
||||
assert.ok(Math.abs(parsedWide.width / parsedWide.height - 16 / 9) < 0.05);
|
||||
assert.ok(parsedWide.width * parsedWide.height <= 2048 * 2048);
|
||||
|
||||
const pro4k = getWan27SizeFromAspectRatio("16:9", "2k", 4096 * 4096);
|
||||
const parsed4k = parseSize(pro4k);
|
||||
assert.ok(parsed4k);
|
||||
assert.ok(parsed4k.width * parsed4k.height > 2048 * 2048);
|
||||
assert.ok(parsed4k.width * parsed4k.height <= 4096 * 4096);
|
||||
});
|
||||
|
||||
test("Wan 2.7 rejects aspect ratios outside the [1:8, 8:1] range", () => {
|
||||
assert.throws(
|
||||
() => getWan27SizeFromAspectRatio("9:1", "2k", 2048 * 2048),
|
||||
/1:8, 8:1/,
|
||||
);
|
||||
assert.throws(
|
||||
() => getWan27SizeFromAspectRatio("1:9", "normal", 2048 * 2048),
|
||||
/1:8, 8:1/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Wan 2.7 derived sizes stay inside the boundary ratio limits after rounding", () => {
|
||||
for (const ar of ["8:1", "1:8"]) {
|
||||
const size = getWan27SizeFromAspectRatio(ar, "2k", 2048 * 2048);
|
||||
const parsed = parseSize(size);
|
||||
assert.ok(parsed);
|
||||
const ratio = parsed.width / parsed.height;
|
||||
assert.ok(ratio >= 1 / 8);
|
||||
assert.ok(ratio <= 8);
|
||||
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSizeForModel routes wan2.7-image to the 2K-capped derivation", () => {
|
||||
const size = resolveSizeForModel("wan2.7-image", {
|
||||
size: null,
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
});
|
||||
const parsed = parseSize(size);
|
||||
assert.ok(parsed);
|
||||
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
||||
assert.ok(Math.abs(parsed.width / parsed.height - 16 / 9) < 0.05);
|
||||
});
|
||||
|
||||
test("resolveSizeForModel allows wan2.7-image-pro 4K only when there are no reference images", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("wan2.7-image-pro", {
|
||||
size: "4096*4096",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
"4096*4096",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("wan2.7-image-pro", {
|
||||
size: "4096*4096",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
referenceImages: ["a.png"],
|
||||
}),
|
||||
/total pixels between 768\*768 and 2048\*2048/,
|
||||
);
|
||||
|
||||
const proWithRef = resolveSizeForModel("wan2.7-image-pro", {
|
||||
size: null,
|
||||
aspectRatio: "1:1",
|
||||
quality: "2k",
|
||||
referenceImages: ["a.png"],
|
||||
});
|
||||
const parsedRef = parseSize(proWithRef);
|
||||
assert.ok(parsedRef);
|
||||
assert.ok(parsedRef.width * parsedRef.height <= 2048 * 2048);
|
||||
});
|
||||
|
||||
test("Wan 2.7 request body forces n=1 and omits prompt_extend / negative_prompt", async (t) => {
|
||||
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: any = null;
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
output: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
await generateImage("hello", "wan2.7-image-pro", makeCliArgs({ aspectRatio: "1:1" }));
|
||||
|
||||
assert.equal(capturedBody.model, "wan2.7-image-pro");
|
||||
assert.deepEqual(Object.keys(capturedBody.parameters).sort(), ["n", "size", "watermark"]);
|
||||
assert.equal(capturedBody.parameters.n, 1);
|
||||
assert.equal(capturedBody.parameters.watermark, false);
|
||||
assert.equal(typeof capturedBody.parameters.size, "string");
|
||||
assert.ok(!("prompt_extend" in capturedBody.parameters));
|
||||
assert.ok(!("negative_prompt" in capturedBody.parameters));
|
||||
|
||||
assert.deepEqual(capturedBody.input.messages[0].content, [{ text: "hello" }]);
|
||||
});
|
||||
|
||||
test("Wan 2.7 request body forwards remote reference image URLs", async (t) => {
|
||||
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody: any = null;
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
output: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [{ image: "data:image/png;base64,iVBORw0KGgo=" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
await generateImage(
|
||||
"combine these",
|
||||
"wan2.7-image-pro",
|
||||
makeCliArgs({ referenceImages: ["https://example.com/ref.png"] }),
|
||||
);
|
||||
|
||||
assert.deepEqual(capturedBody.input.messages[0].content, [
|
||||
{ image: "https://example.com/ref.png" },
|
||||
{ text: "combine these" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Wan 2.7 rejects --n > 1 to prevent silent multi-image billing", async (t) => {
|
||||
useEnv(t, { DASHSCOPE_API_KEY: "fake-key" });
|
||||
|
||||
await assert.rejects(
|
||||
() => generateImage("hi", "wan2.7-image-pro", makeCliArgs({ n: 2 })),
|
||||
/support exactly one output image/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSizeForModel validates explicit wan2.7 sizes by pixel budget and ratio", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("wan2.7-image-pro", {
|
||||
size: "3840x2160",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
"3840*2160",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("wan2.7-image-pro", {
|
||||
size: "3840x2160",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
referenceImages: ["a.png"],
|
||||
}),
|
||||
/total pixels between 768\*768 and 2048\*2048/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("wan2.7-image", {
|
||||
size: "4096x4096",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
/total pixels between 768\*768 and 2048\*2048/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("wan2.7-image-pro", {
|
||||
size: "3072*256",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
/1:8, 8:1/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs, Quality } from "../types";
|
||||
|
||||
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
|
||||
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "wan27" | "legacy";
|
||||
|
||||
type DashScopeModelSpec = {
|
||||
family: DashScopeModelFamily;
|
||||
@@ -19,6 +21,16 @@ const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
|
||||
"2k": 1536 * 1536,
|
||||
};
|
||||
|
||||
const MIN_WAN27_TOTAL_PIXELS = 768 * 768;
|
||||
const MAX_WAN27_PRO_T2I_PIXELS = 4096 * 4096;
|
||||
const MAX_WAN27_GENERAL_PIXELS = 2048 * 2048;
|
||||
const WAN27_MAX_REFERENCE_IMAGES = 9;
|
||||
|
||||
const WAN27_TARGET_PIXELS: Record<Quality, number> = {
|
||||
normal: 1024 * 1024,
|
||||
"2k": 2048 * 2048,
|
||||
};
|
||||
|
||||
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
|
||||
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
|
||||
"2:3": { normal: "768*1152", "2k": "1024*1536" },
|
||||
@@ -73,6 +85,11 @@ const QWEN_FIXED_SPEC: DashScopeModelSpec = {
|
||||
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
|
||||
};
|
||||
|
||||
const WAN27_SPEC: DashScopeModelSpec = {
|
||||
family: "wan27",
|
||||
defaultSize: "2048*2048",
|
||||
};
|
||||
|
||||
const LEGACY_SPEC: DashScopeModelSpec = {
|
||||
family: "legacy",
|
||||
defaultSize: "1536*1536",
|
||||
@@ -88,12 +105,31 @@ const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
|
||||
"qwen-image-plus": QWEN_FIXED_SPEC,
|
||||
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
|
||||
"qwen-image": QWEN_FIXED_SPEC,
|
||||
"wan2.7-image-pro": WAN27_SPEC,
|
||||
"wan2.7-image": WAN27_SPEC,
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getReferenceImageMime(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".bmp") return "image/bmp";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function loadReferenceImage(refPath: string): Promise<string> {
|
||||
if (/^https?:\/\//i.test(refPath)) {
|
||||
return refPath;
|
||||
}
|
||||
const fullPath = path.resolve(refPath);
|
||||
const bytes = await readFile(fullPath);
|
||||
return `data:${getReferenceImageMime(fullPath)};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DASHSCOPE_API_KEY || null;
|
||||
}
|
||||
@@ -173,6 +209,10 @@ function roundToStep(value: number): number {
|
||||
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
|
||||
}
|
||||
|
||||
function floorToStep(value: number): number {
|
||||
return Math.max(SIZE_STEP, Math.floor(value / SIZE_STEP) * SIZE_STEP);
|
||||
}
|
||||
|
||||
function fitToPixelBudget(
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -220,6 +260,21 @@ function fitToPixelBudget(
|
||||
return { width: roundedWidth, height: roundedHeight };
|
||||
}
|
||||
|
||||
function clampWan27DerivedSizeToRatioBounds(
|
||||
size: { width: number; height: number },
|
||||
): { width: number; height: number } {
|
||||
let { width, height } = size;
|
||||
const ratio = width / height;
|
||||
|
||||
if (ratio > 8) {
|
||||
width = floorToStep(height * 8);
|
||||
} else if (ratio < 1 / 8) {
|
||||
height = floorToStep(width * 8);
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const normalizedQuality = normalizeQuality(quality);
|
||||
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
|
||||
@@ -276,6 +331,77 @@ export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs[
|
||||
return formatSize(fitted.width, fitted.height);
|
||||
}
|
||||
|
||||
function isWan27ProModel(model: string): boolean {
|
||||
return model.trim().toLowerCase() === "wan2.7-image-pro";
|
||||
}
|
||||
|
||||
function getWan27MaxPixels(model: string, hasReferenceImages: boolean): number {
|
||||
if (isWan27ProModel(model) && !hasReferenceImages) {
|
||||
return MAX_WAN27_PRO_T2I_PIXELS;
|
||||
}
|
||||
return MAX_WAN27_GENERAL_PIXELS;
|
||||
}
|
||||
|
||||
export function getWan27SizeFromAspectRatio(
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"],
|
||||
maxPixels: number,
|
||||
): string {
|
||||
const normalizedQuality = normalizeQuality(quality);
|
||||
const targetPixels = Math.min(WAN27_TARGET_PIXELS[normalizedQuality], maxPixels);
|
||||
|
||||
if (!ar) {
|
||||
const side = roundToStep(Math.sqrt(targetPixels));
|
||||
return formatSize(side, side);
|
||||
}
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) {
|
||||
const side = roundToStep(Math.sqrt(targetPixels));
|
||||
return formatSize(side, side);
|
||||
}
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (ratio < 1 / 8 || ratio > 8) {
|
||||
throw new Error(
|
||||
`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. Received "${ar}".`
|
||||
);
|
||||
}
|
||||
|
||||
const rawWidth = Math.sqrt(targetPixels * ratio);
|
||||
const rawHeight = Math.sqrt(targetPixels / ratio);
|
||||
const fitted = fitToPixelBudget(
|
||||
rawWidth,
|
||||
rawHeight,
|
||||
MIN_WAN27_TOTAL_PIXELS,
|
||||
maxPixels,
|
||||
);
|
||||
const bounded = clampWan27DerivedSizeToRatioBounds(fitted);
|
||||
|
||||
return formatSize(bounded.width, bounded.height);
|
||||
}
|
||||
|
||||
function validateWan27Size(size: string, maxPixels: number, model: string): string {
|
||||
const normalized = normalizeSize(size);
|
||||
const parsed = validateSizeFormat(normalized);
|
||||
const totalPixels = parsed.width * parsed.height;
|
||||
if (totalPixels < MIN_WAN27_TOTAL_PIXELS || totalPixels > maxPixels) {
|
||||
const limit = maxPixels === MAX_WAN27_PRO_T2I_PIXELS ? "4096*4096" : "2048*2048";
|
||||
throw new Error(
|
||||
`DashScope ${model} requires total pixels between 768*768 and ${limit} ` +
|
||||
`for the current request. Received ${normalized} (${totalPixels} pixels).`
|
||||
);
|
||||
}
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (ratio < 1 / 8 || ratio > 8) {
|
||||
throw new Error(
|
||||
`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. ` +
|
||||
`Received ${normalized} (ratio ${ratio.toFixed(3)}).`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
if (quality === "normal") {
|
||||
console.warn(
|
||||
@@ -331,9 +457,16 @@ function validateQwenFixedSize(size: string): string {
|
||||
|
||||
export function resolveSizeForModel(
|
||||
model: string,
|
||||
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
|
||||
args: Pick<CliArgs, "size" | "aspectRatio" | "quality"> & { referenceImages?: string[] },
|
||||
): string {
|
||||
const spec = getModelSpec(model);
|
||||
const referenceCount = args.referenceImages?.length ?? 0;
|
||||
|
||||
if (spec.family === "wan27") {
|
||||
const maxPixels = getWan27MaxPixels(model, referenceCount > 0);
|
||||
if (args.size) return validateWan27Size(args.size, maxPixels, model);
|
||||
return getWan27SizeFromAspectRatio(args.aspectRatio, args.quality, maxPixels);
|
||||
}
|
||||
|
||||
if (args.size) {
|
||||
if (spec.family === "qwen2") return validateQwen2Size(args.size);
|
||||
@@ -357,6 +490,14 @@ function buildParameters(
|
||||
family: DashScopeModelFamily,
|
||||
size: string,
|
||||
): Record<string, unknown> {
|
||||
if (family === "wan27") {
|
||||
return {
|
||||
size,
|
||||
n: 1,
|
||||
watermark: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parameters: Record<string, unknown> = {
|
||||
prompt_extend: false,
|
||||
size,
|
||||
@@ -419,23 +560,44 @@ export async function generateImage(
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
const spec = getModelSpec(model);
|
||||
|
||||
if (args.referenceImages.length > 0 && spec.family !== "wan27") {
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
"Reference images are not supported with this DashScope model. Use a wan2.7 image model (--model wan2.7-image-pro or wan2.7-image), or switch to --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
if (args.referenceImages.length > WAN27_MAX_REFERENCE_IMAGES) {
|
||||
throw new Error(
|
||||
`DashScope wan2.7 image models accept at most ${WAN27_MAX_REFERENCE_IMAGES} reference images. Received ${args.referenceImages.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (spec.family === "wan27" && args.n !== 1) {
|
||||
throw new Error(
|
||||
"DashScope wan2.7 image models in baoyu-image-gen support exactly one output image per request (extra images would be billed but discarded). Remove --n or use --n 1."
|
||||
);
|
||||
}
|
||||
|
||||
const spec = getModelSpec(model);
|
||||
const size = resolveSizeForModel(model, args);
|
||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
if (spec.family === "wan27" && args.referenceImages.length > 0) {
|
||||
for (const refPath of args.referenceImages) {
|
||||
content.push({ image: await loadReferenceImage(refPath) });
|
||||
}
|
||||
}
|
||||
content.push({ text: prompt });
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: prompt }],
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildOpenAIGenerationsBody,
|
||||
extractImageFromResponse,
|
||||
getDefaultModel,
|
||||
getOpenAIAspectRatio,
|
||||
getOpenAIImageApiDialect,
|
||||
getOpenAIResolution,
|
||||
getMimeType,
|
||||
getOpenAISize,
|
||||
getOrientationFromAspectRatio,
|
||||
inferAspectRatioFromSize,
|
||||
inferResolutionFromSize,
|
||||
parseAspectRatio,
|
||||
validateArgs,
|
||||
} from "./openai.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("OpenAI aspect-ratio parsing and size selection match model families", () => {
|
||||
assert.equal(getDefaultModel(), "gpt-image-2");
|
||||
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
|
||||
assert.equal(parseAspectRatio("wide"), null);
|
||||
assert.equal(parseAspectRatio("0:1"), null);
|
||||
@@ -18,6 +51,96 @@ test("OpenAI aspect-ratio parsing and size selection match model families", () =
|
||||
assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024");
|
||||
assert.equal(getOpenAISize("gpt-image-2", "16:9", "2k"), "2048x1152");
|
||||
assert.equal(getOpenAISize("gpt-image-2", "9:16", "2k"), "1152x2048");
|
||||
assert.equal(getOpenAISize("gpt-image-2", "4:3", "2k"), "2048x1536");
|
||||
assert.equal(getOpenAISize("gpt-image-2", "2.35:1", "normal"), "1248x528");
|
||||
assert.equal(inferAspectRatioFromSize("1536x1024"), "3:2");
|
||||
assert.equal(inferResolutionFromSize("1536x1024"), "2K");
|
||||
assert.equal(getOpenAIAspectRatio({ aspectRatio: null, size: "2048x1152" }), "16:9");
|
||||
assert.equal(getOpenAIResolution({ imageSize: null, size: "2048x1152", quality: "normal" }), "2K");
|
||||
assert.equal(getOrientationFromAspectRatio("16:9"), "landscape");
|
||||
assert.equal(getOrientationFromAspectRatio("9:16"), "portrait");
|
||||
assert.equal(getOrientationFromAspectRatio("1:1"), null);
|
||||
assert.equal(getOpenAIImageApiDialect({ imageApiDialect: null }), "openai-native");
|
||||
});
|
||||
|
||||
test("OpenAI generations body switches between native and ratio-metadata dialects", () => {
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gpt-image-2", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
}),
|
||||
{
|
||||
model: "gpt-image-2",
|
||||
prompt: "Draw a skyline",
|
||||
size: "2048x1152",
|
||||
quality: "high",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a skyline", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: "16:9",
|
||||
size: null,
|
||||
quality: "2k",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a skyline",
|
||||
size: "16:9",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "landscape",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildOpenAIGenerationsBody("Draw a portrait", "gemini-3-pro-image-preview", {
|
||||
aspectRatio: null,
|
||||
size: "1152x2048",
|
||||
quality: "normal",
|
||||
imageSize: null,
|
||||
imageApiDialect: "ratio-metadata",
|
||||
}),
|
||||
{
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "Draw a portrait",
|
||||
size: "9:16",
|
||||
metadata: {
|
||||
resolution: "2K",
|
||||
orientation: "portrait",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI validates gpt-image-2 custom size constraints", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs("gpt-image-2", makeArgs({ size: "3840x2160" })),
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs("gpt-image-2-2026-04-21", makeArgs({ aspectRatio: "2.35:1" })),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => validateArgs("gpt-image-2", makeArgs({ size: "1024x576" })),
|
||||
/total pixels/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("gpt-image-2", makeArgs({ size: "1025x1024" })),
|
||||
/multiples of 16px/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArgs("gpt-image-2", makeArgs({ aspectRatio: "4:1" })),
|
||||
/must not exceed 3:1/,
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI mime-type detection covers supported reference image extensions", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
import type { CliArgs, OpenAIImageApiDialect } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||
return process.env.OPENAI_IMAGE_MODEL || "gpt-image-2";
|
||||
}
|
||||
|
||||
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||
@@ -23,6 +23,57 @@ type SizeMapping = {
|
||||
portrait: string;
|
||||
};
|
||||
|
||||
type OpenAIGenerationsBody = Record<string, unknown>;
|
||||
|
||||
function isGptImageModel(model: string): boolean {
|
||||
return model.includes("gpt-image");
|
||||
}
|
||||
|
||||
function isGptImage2Model(model: string): boolean {
|
||||
return model.includes("gpt-image-2");
|
||||
}
|
||||
|
||||
function roundToMultiple(value: number, multiple: number): number {
|
||||
return Math.max(multiple, Math.round(value / multiple) * multiple);
|
||||
}
|
||||
|
||||
function buildGptImage2SizeFromAspectRatio(
|
||||
ar: string | null,
|
||||
quality: CliArgs["quality"],
|
||||
): string {
|
||||
const parsed = ar ? parseAspectRatio(ar) : null;
|
||||
const ratio = parsed ? parsed.width / parsed.height : 1;
|
||||
|
||||
if (!parsed || Math.abs(ratio - 1) < 0.1) {
|
||||
const edge = quality === "2k" ? 2048 : 1024;
|
||||
return `${edge}x${edge}`;
|
||||
}
|
||||
|
||||
const targetLongEdge = quality === "2k" ? 2048 : 1024;
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if (ratio > 1) {
|
||||
width = targetLongEdge;
|
||||
height = roundToMultiple(width / ratio, 16);
|
||||
} else {
|
||||
height = targetLongEdge;
|
||||
width = roundToMultiple(height * ratio, 16);
|
||||
}
|
||||
|
||||
while (width * height < 655_360) {
|
||||
if (ratio > 1) {
|
||||
width += 16;
|
||||
height = roundToMultiple(width / ratio, 16);
|
||||
} else {
|
||||
height += 16;
|
||||
width = roundToMultiple(height * ratio, 16);
|
||||
}
|
||||
}
|
||||
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
export function getOpenAISize(
|
||||
model: string,
|
||||
ar: string | null,
|
||||
@@ -35,6 +86,10 @@ export function getOpenAISize(
|
||||
return "1024x1024";
|
||||
}
|
||||
|
||||
if (isGptImage2Model(model)) {
|
||||
return buildGptImage2SizeFromAspectRatio(ar, quality);
|
||||
}
|
||||
|
||||
const sizes: SizeMapping = isDalle3
|
||||
? {
|
||||
square: "1024x1024",
|
||||
@@ -60,6 +115,166 @@ export function getOpenAISize(
|
||||
return sizes.square;
|
||||
}
|
||||
|
||||
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||
const match = value.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 gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
export function getOpenAIImageApiDialect(args: Pick<CliArgs, "imageApiDialect">): OpenAIImageApiDialect {
|
||||
return args.imageApiDialect ?? "openai-native";
|
||||
}
|
||||
|
||||
export function inferAspectRatioFromSize(size: string | null): string | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
return `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
}
|
||||
|
||||
export function inferResolutionFromSize(size: string | null): "1K" | "2K" | "4K" | null {
|
||||
if (!size) return null;
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
return "4K";
|
||||
}
|
||||
|
||||
export function getOpenAIAspectRatio(args: Pick<CliArgs, "aspectRatio" | "size">): string {
|
||||
return args.aspectRatio ?? inferAspectRatioFromSize(args.size) ?? "1:1";
|
||||
}
|
||||
|
||||
export function getOpenAIResolution(
|
||||
args: Pick<CliArgs, "imageSize" | "size" | "quality">
|
||||
): "1K" | "2K" | "4K" {
|
||||
if (args.imageSize === "1K" || args.imageSize === "2K" || args.imageSize === "4K") {
|
||||
return args.imageSize;
|
||||
}
|
||||
|
||||
const inferred = inferResolutionFromSize(args.size);
|
||||
if (inferred) return inferred;
|
||||
|
||||
return args.quality === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function getOpenAIQuality(model: string, quality: CliArgs["quality"]): "standard" | "hd" | "medium" | "high" | null {
|
||||
if (model.includes("dall-e-3")) {
|
||||
return quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
if (isGptImageModel(model)) {
|
||||
return quality === "2k" ? "high" : "medium";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getOrientationFromAspectRatio(ar: string): "landscape" | "portrait" | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (Math.abs(ratio - 1) < 0.1) return null;
|
||||
return ratio > 1 ? "landscape" : "portrait";
|
||||
}
|
||||
|
||||
export function buildOpenAIGenerationsBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: Pick<CliArgs, "aspectRatio" | "size" | "quality" | "imageSize" | "imageApiDialect">
|
||||
): OpenAIGenerationsBody {
|
||||
if (getOpenAIImageApiDialect(args) === "ratio-metadata") {
|
||||
const aspectRatio = getOpenAIAspectRatio(args);
|
||||
const metadata: Record<string, string> = {
|
||||
resolution: getOpenAIResolution(args),
|
||||
};
|
||||
const orientation = getOrientationFromAspectRatio(aspectRatio);
|
||||
if (orientation) metadata.orientation = orientation;
|
||||
|
||||
return {
|
||||
model,
|
||||
prompt,
|
||||
size: aspectRatio,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const body: OpenAIGenerationsBody = {
|
||||
model,
|
||||
prompt,
|
||||
size: args.size || getOpenAISize(model, args.aspectRatio, args.quality),
|
||||
};
|
||||
|
||||
const quality = getOpenAIQuality(model, args.quality);
|
||||
if (quality) {
|
||||
body.quality = quality;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
if (!isGptImage2Model(model)) return;
|
||||
|
||||
if (args.aspectRatio && !args.size) {
|
||||
const parsed = parseAspectRatio(args.aspectRatio);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid gpt-image-2 aspect ratio: ${args.aspectRatio}`);
|
||||
}
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (Math.max(ratio, 1 / ratio) > 3) {
|
||||
throw new Error("gpt-image-2 aspect ratio must not exceed 3:1.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.size) return;
|
||||
|
||||
const parsedSize = parsePixelSize(args.size);
|
||||
if (!parsedSize) {
|
||||
throw new Error(`Invalid gpt-image-2 --size: ${args.size}. Expected <width>x<height>.`);
|
||||
}
|
||||
|
||||
const { width, height } = parsedSize;
|
||||
const totalPixels = width * height;
|
||||
const ratio = Math.max(width, height) / Math.min(width, height);
|
||||
|
||||
if (Math.max(width, height) > 3840) {
|
||||
throw new Error("gpt-image-2 --size maximum edge length must be 3840px or less.");
|
||||
}
|
||||
if (width % 16 !== 0 || height % 16 !== 0) {
|
||||
throw new Error("gpt-image-2 --size width and height must both be multiples of 16px.");
|
||||
}
|
||||
if (ratio > 3) {
|
||||
throw new Error("gpt-image-2 --size long edge to short edge ratio must not exceed 3:1.");
|
||||
}
|
||||
if (totalPixels < 655_360 || totalPixels > 8_294_400) {
|
||||
throw new Error("gpt-image-2 --size total pixels must be between 655,360 and 8,294,400.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
@@ -78,18 +293,28 @@ export async function generateImage(
|
||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||
}
|
||||
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
const imageApiDialect = getOpenAIImageApiDialect(args);
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
if (imageApiDialect !== "openai-native") {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-1.5 (or another gpt-image model)."
|
||||
"Reference images are not supported with the ratio-metadata OpenAI dialect yet. Use openai-native, Google, Azure, OpenRouter, MiniMax, Seedream, or Replicate for image-edit workflows."
|
||||
);
|
||||
}
|
||||
if (model.includes("dall-e-2") || model.includes("dall-e-3")) {
|
||||
throw new Error(
|
||||
"Reference images with OpenAI in this skill require GPT Image models. Use --model gpt-image-2 (or another gpt-image model)."
|
||||
);
|
||||
}
|
||||
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||
return generateWithOpenAIEdits(baseURL, apiKey, prompt, model, size, args.referenceImages, args.quality);
|
||||
}
|
||||
|
||||
return generateWithOpenAIGenerations(baseURL, apiKey, prompt, model, size, args.quality);
|
||||
return generateWithOpenAIGenerations(
|
||||
baseURL,
|
||||
apiKey,
|
||||
buildOpenAIGenerationsBody(prompt, model, args)
|
||||
);
|
||||
}
|
||||
|
||||
async function generateWithChatCompletions(
|
||||
@@ -129,17 +354,8 @@ async function generateWithChatCompletions(
|
||||
async function generateWithOpenAIGenerations(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
quality: CliArgs["quality"]
|
||||
body: OpenAIGenerationsBody
|
||||
): Promise<Uint8Array> {
|
||||
const body: Record<string, any> = { model, prompt, size };
|
||||
|
||||
if (model.includes("dall-e-3")) {
|
||||
body.quality = quality === "2k" ? "hd" : "standard";
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseURL}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -172,8 +388,9 @@ async function generateWithOpenAIEdits(
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
|
||||
if (model.includes("gpt-image")) {
|
||||
form.append("quality", quality === "2k" ? "high" : "medium");
|
||||
const openAIQuality = getOpenAIQuality(model, quality);
|
||||
if (openAIQuality && openAIQuality !== "standard" && openAIQuality !== "hd") {
|
||||
form.append("quality", openAIQuality);
|
||||
}
|
||||
|
||||
for (const refPath of referenceImages) {
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildInput,
|
||||
extractOutputUrl,
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
parseModelId,
|
||||
validateArgs,
|
||||
} from "./replicate.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
@@ -16,9 +19,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
aspectRatioSource: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageSizeSource: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
@@ -29,10 +35,24 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
test("Replicate model parsing accepts official formats and rejects malformed ones", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-pro"), {
|
||||
test("Replicate default model now points at nano-banana-2", () => {
|
||||
const previous = process.env.REPLICATE_IMAGE_MODEL;
|
||||
delete process.env.REPLICATE_IMAGE_MODEL;
|
||||
try {
|
||||
assert.equal(getDefaultModel(), "google/nano-banana-2");
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
delete process.env.REPLICATE_IMAGE_MODEL;
|
||||
} else {
|
||||
process.env.REPLICATE_IMAGE_MODEL = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Replicate model parsing and family detection accept supported official ids", () => {
|
||||
assert.deepEqual(parseModelId("google/nano-banana-2"), {
|
||||
owner: "google",
|
||||
name: "nano-banana-pro",
|
||||
name: "nano-banana-2",
|
||||
version: null,
|
||||
});
|
||||
assert.deepEqual(parseModelId("owner/model:abc123"), {
|
||||
@@ -41,46 +61,224 @@ test("Replicate model parsing accepts official formats and rejects malformed one
|
||||
version: "abc123",
|
||||
});
|
||||
|
||||
assert.equal(getModelFamily("google/nano-banana-pro"), "nano-banana");
|
||||
assert.equal(getModelFamily("bytedance/seedream-4.5"), "seedream45");
|
||||
assert.equal(getModelFamily("bytedance/seedream-5-lite"), "seedream5lite");
|
||||
assert.equal(getModelFamily("wan-video/wan-2.7-image"), "wan27image");
|
||||
assert.equal(getModelFamily("wan-video/wan-2.7-image-pro"), "wan27imagepro");
|
||||
assert.equal(getModelFamily("stability-ai/sdxl"), "unknown");
|
||||
|
||||
assert.throws(
|
||||
() => parseModelId("just-a-model-name"),
|
||||
/Invalid Replicate model format/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => {
|
||||
test("Replicate nano-banana input builder maps refs, aspect ratio, and quality presets", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"google/nano-banana-2",
|
||||
"A robot painter",
|
||||
makeArgs({
|
||||
aspectRatio: "16:9",
|
||||
quality: "2k",
|
||||
n: 3,
|
||||
}),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "16:9",
|
||||
number_of_images: 3,
|
||||
resolution: "2K",
|
||||
output_format: "png",
|
||||
aspect_ratio: "16:9",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]),
|
||||
buildInput(
|
||||
"google/nano-banana-2",
|
||||
"A robot painter",
|
||||
makeArgs({ size: "1024x1024", quality: "normal" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A robot painter",
|
||||
aspect_ratio: "match_input_image",
|
||||
resolution: "1K",
|
||||
output_format: "png",
|
||||
image_input: ["ref"],
|
||||
aspect_ratio: "1:1",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports string, array, and object URLs", () => {
|
||||
test("Replicate Seedream and Wan inputs use family-specific request fields", () => {
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-4.5",
|
||||
"A cinematic portrait",
|
||||
makeArgs({ quality: "2k", referenceImages: ["local.png"] }),
|
||||
["data:image/png;base64,AAAA"],
|
||||
),
|
||||
{
|
||||
prompt: "A cinematic portrait",
|
||||
size: "4K",
|
||||
image_input: ["data:image/png;base64,AAAA"],
|
||||
aspect_ratio: "match_input_image",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-4.5",
|
||||
"A cinematic portrait",
|
||||
makeArgs({ size: "1536x1024" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A cinematic portrait",
|
||||
size: "custom",
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"bytedance/seedream-5-lite",
|
||||
"A poster",
|
||||
makeArgs({ aspectRatio: "21:9", quality: "2k" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A poster",
|
||||
size: "3K",
|
||||
aspect_ratio: "21:9",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"wan-video/wan-2.7-image",
|
||||
"A storyboard frame",
|
||||
makeArgs({ aspectRatio: "16:9", quality: "2k" }),
|
||||
[],
|
||||
),
|
||||
{
|
||||
prompt: "A storyboard frame",
|
||||
size: "2048*1152",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildInput(
|
||||
"wan-video/wan-2.7-image-pro",
|
||||
"Blend these references",
|
||||
makeArgs({ size: "2K", referenceImages: ["a.png", "b.png"] }),
|
||||
["ref-a", "ref-b"],
|
||||
),
|
||||
{
|
||||
prompt: "Blend these references",
|
||||
size: "2K",
|
||||
images: ["ref-a", "ref-b"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate validateArgs blocks misleading multi-output and unsupported family options locally", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ n: 2 }),
|
||||
),
|
||||
/exactly one output image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"bytedance/seedream-4.5",
|
||||
makeArgs({ size: "1K" }),
|
||||
),
|
||||
/2K, 4K, or an explicit WxH size/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"bytedance/seedream-5-lite",
|
||||
makeArgs({ size: "4K" }),
|
||||
),
|
||||
/supports 2K or 3K output/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"wan-video/wan-2.7-image",
|
||||
makeArgs({ referenceImages: new Array(10).fill("ref.png") }),
|
||||
),
|
||||
/at most 9 reference images/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"wan-video/wan-2.7-image-pro",
|
||||
makeArgs({ referenceImages: ["ref.png"], size: "4K" }),
|
||||
),
|
||||
/only supports 4K text-to-image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9" }),
|
||||
),
|
||||
/compatibility list/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ imageSize: "2K", imageSizeSource: "config" }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"google/nano-banana-2",
|
||||
makeArgs({ imageSize: "2K", imageSizeSource: "cli" }),
|
||||
),
|
||||
/do not use --imageSize/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "config" }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs({ aspectRatio: "16:9", aspectRatioSource: "cli" }),
|
||||
),
|
||||
/compatibility list/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"stability-ai/sdxl",
|
||||
makeArgs(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Replicate output extraction supports single outputs and rejects silent multi-image drops", () => {
|
||||
assert.equal(
|
||||
extractOutputUrl({ output: "https://example.com/a.png" } as never),
|
||||
"https://example.com/a.png",
|
||||
@@ -94,6 +292,17 @@ test("Replicate output extraction supports string, array, and object URLs", () =
|
||||
"https://example.com/c.png",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
extractOutputUrl({
|
||||
output: [
|
||||
"https://example.com/one.png",
|
||||
"https://example.com/two.png",
|
||||
],
|
||||
} as never),
|
||||
/supports saving exactly one image/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => extractOutputUrl({ output: { invalid: true } } as never),
|
||||
/Unexpected Replicate output format/,
|
||||
|
||||
@@ -2,10 +2,37 @@ import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const DEFAULT_MODEL = "google/nano-banana-pro";
|
||||
const DEFAULT_MODEL = "google/nano-banana-2";
|
||||
const SYNC_WAIT_SECONDS = 60;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_MS = 300_000;
|
||||
const DOCUMENTED_REPLICATE_ASPECT_RATIOS = new Set([
|
||||
"1:1",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"5:4",
|
||||
"4:5",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
]);
|
||||
|
||||
export type ReplicateModelFamily =
|
||||
| "nano-banana"
|
||||
| "seedream45"
|
||||
| "seedream5lite"
|
||||
| "wan27image"
|
||||
| "wan27imagepro"
|
||||
| "unknown";
|
||||
|
||||
type PixelSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Seedream45Size = "2K" | "4K" | { width: number; height: number };
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
@@ -20,6 +47,40 @@ function getBaseUrl(): string {
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeModelId(model: string): string {
|
||||
return model.trim().toLowerCase().split(":")[0]!;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): ReplicateModelFamily {
|
||||
const normalized = normalizeModelId(model);
|
||||
|
||||
if (
|
||||
normalized === "google/nano-banana" ||
|
||||
normalized === "google/nano-banana-pro" ||
|
||||
normalized === "google/nano-banana-2"
|
||||
) {
|
||||
return "nano-banana";
|
||||
}
|
||||
|
||||
if (normalized === "bytedance/seedream-4.5") {
|
||||
return "seedream45";
|
||||
}
|
||||
|
||||
if (normalized === "bytedance/seedream-5-lite") {
|
||||
return "seedream5lite";
|
||||
}
|
||||
|
||||
if (normalized === "wan-video/wan-2.7-image") {
|
||||
return "wan27image";
|
||||
}
|
||||
|
||||
if (normalized === "wan-video/wan-2.7-image-pro") {
|
||||
return "wan27imagepro";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function parseModelId(model: string): { owner: string; name: string; version: string | null } {
|
||||
const [ownerName, version] = model.split(":");
|
||||
const parts = ownerName!.split("/");
|
||||
@@ -31,27 +92,219 @@ export function parseModelId(model: string): { owner: string; name: string; vers
|
||||
return { owner: parts[0], name: parts[1], version: version || null };
|
||||
}
|
||||
|
||||
export function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = { prompt };
|
||||
function parsePixelSize(value: string): PixelSize | null {
|
||||
const match = value.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 parseAspectRatio(value: string): PixelSize | null {
|
||||
const match = value.trim().match(/^(\d+)\s*:\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 gcd(a: number, b: number): number {
|
||||
let x = Math.abs(a);
|
||||
let y = Math.abs(b);
|
||||
|
||||
while (y !== 0) {
|
||||
const next = x % y;
|
||||
x = y;
|
||||
y = next;
|
||||
}
|
||||
|
||||
return x || 1;
|
||||
}
|
||||
|
||||
function inferAspectRatioFromSize(size: string): string | null {
|
||||
const parsed = parsePixelSize(size);
|
||||
if (!parsed) return null;
|
||||
|
||||
const divisor = gcd(parsed.width, parsed.height);
|
||||
const normalized = `${parsed.width / divisor}:${parsed.height / divisor}`;
|
||||
if (!DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getQualityPreset(args: CliArgs): "normal" | "2k" {
|
||||
return args.quality === "normal" ? "normal" : "2k";
|
||||
}
|
||||
|
||||
function validateDocumentedAspectRatio(model: string, aspectRatio: string): void {
|
||||
if (aspectRatio === "match_input_image") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DOCUMENTED_REPLICATE_ASPECT_RATIOS.has(aspectRatio)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Replicate model ${model} does not support aspect ratio ${aspectRatio}. Supported values: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestedAspectRatio(model: string, args: CliArgs): string | null {
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
return args.aspectRatio;
|
||||
}
|
||||
|
||||
if (!args.size) return null;
|
||||
|
||||
const inferred = inferAspectRatioFromSize(args.size);
|
||||
if (!inferred) {
|
||||
throw new Error(
|
||||
`Replicate model ${model} cannot derive a supported aspect ratio from --size ${args.size}. Use one of: ${Array.from(DOCUMENTED_REPLICATE_ASPECT_RATIOS).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function getNanoBananaResolution(args: CliArgs): "1K" | "2K" {
|
||||
if (args.size) {
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate nano-banana --size must be in WxH format, for example 1536x1024.");
|
||||
}
|
||||
|
||||
const longestEdge = Math.max(parsed.width, parsed.height);
|
||||
if (longestEdge <= 1024) return "1K";
|
||||
if (longestEdge <= 2048) return "2K";
|
||||
throw new Error("Replicate nano-banana only supports sizes that map to 1K or 2K output.");
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function resolveSeedream45Size(args: CliArgs): Seedream45Size {
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "2K" || upper === "4K") {
|
||||
return upper;
|
||||
}
|
||||
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate Seedream 4.5 --size must be 2K, 4K, or an explicit WxH size.");
|
||||
}
|
||||
if (parsed.width < 1024 || parsed.width > 4096 || parsed.height < 1024 || parsed.height > 4096) {
|
||||
throw new Error("Replicate Seedream 4.5 custom --size must keep width and height between 1024 and 4096.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "2K" : "4K";
|
||||
}
|
||||
|
||||
function resolveSeedream5LiteSize(args: CliArgs): "2K" | "3K" {
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper;
|
||||
}
|
||||
|
||||
throw new Error("Replicate Seedream 5 Lite currently supports 2K or 3K output in this tool.");
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "2K" : "3K";
|
||||
}
|
||||
|
||||
function formatCustomWanSize(size: PixelSize): string {
|
||||
return `${size.width}*${size.height}`;
|
||||
}
|
||||
|
||||
function resolveWanSizeFromAspectRatio(
|
||||
aspectRatio: string,
|
||||
maxDimension: number,
|
||||
): string {
|
||||
const parsedRatio = parseAspectRatio(aspectRatio);
|
||||
if (!parsedRatio) {
|
||||
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${aspectRatio}.`);
|
||||
}
|
||||
|
||||
const scale = Math.min(maxDimension / parsedRatio.width, maxDimension / parsedRatio.height);
|
||||
const width = Math.max(1, Math.floor(parsedRatio.width * scale));
|
||||
const height = Math.max(1, Math.floor(parsedRatio.height * scale));
|
||||
return formatCustomWanSize({ width, height });
|
||||
}
|
||||
|
||||
function resolveWanSize(family: "wan27image" | "wan27imagepro", args: CliArgs): "1K" | "2K" | "4K" | string {
|
||||
const referenceMode = args.referenceImages.length > 0;
|
||||
const maxDimension = family === "wan27imagepro" && !referenceMode ? 4096 : 2048;
|
||||
|
||||
if (args.size) {
|
||||
const upper = args.size.trim().toUpperCase();
|
||||
if (upper === "1K" || upper === "2K" || upper === "4K") {
|
||||
if (upper === "4K" && family !== "wan27imagepro") {
|
||||
throw new Error("Replicate Wan 2.7 Image only supports 1K, 2K, or custom sizes up to 2048px.");
|
||||
}
|
||||
if (upper === "4K" && referenceMode) {
|
||||
throw new Error("Replicate Wan 2.7 Image Pro only supports 4K text-to-image. Remove --ref or lower the size.");
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
const parsed = parsePixelSize(args.size);
|
||||
if (!parsed) {
|
||||
throw new Error("Replicate Wan --size must be 1K, 2K, 4K, or an explicit WxH size.");
|
||||
}
|
||||
if (parsed.width > maxDimension || parsed.height > maxDimension) {
|
||||
throw new Error(
|
||||
`Replicate ${family === "wan27imagepro" ? "Wan 2.7 Image Pro" : "Wan 2.7 Image"} custom --size must keep width and height at or below ${maxDimension}px in the current mode.`
|
||||
);
|
||||
}
|
||||
return formatCustomWanSize(parsed);
|
||||
}
|
||||
|
||||
if (args.aspectRatio) {
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
return resolveWanSizeFromAspectRatio(
|
||||
args.aspectRatio,
|
||||
getQualityPreset(args) === "normal" ? 1024 : 2048,
|
||||
);
|
||||
}
|
||||
|
||||
return getQualityPreset(args) === "normal" ? "1K" : "2K";
|
||||
}
|
||||
|
||||
function buildNanoBananaInput(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
resolution: getNanoBananaResolution(args),
|
||||
output_format: "png",
|
||||
};
|
||||
|
||||
const aspectRatio = getRequestedAspectRatio(model, args);
|
||||
if (aspectRatio) {
|
||||
input.aspect_ratio = aspectRatio;
|
||||
} else if (referenceImages.length > 0) {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
input.number_of_images = args.n;
|
||||
}
|
||||
|
||||
if (args.quality === "normal") {
|
||||
input.resolution = "1K";
|
||||
} else if (args.quality === "2k") {
|
||||
input.resolution = "2K";
|
||||
}
|
||||
|
||||
input.output_format = "png";
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
@@ -59,6 +312,158 @@ export function buildInput(prompt: string, args: CliArgs, referenceImages: strin
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildSeedreamInput(
|
||||
family: "seedream45" | "seedream5lite",
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const size = family === "seedream45" ? resolveSeedream45Size(args) : resolveSeedream5LiteSize(args);
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
};
|
||||
|
||||
if (family === "seedream45" && typeof size === "object") {
|
||||
input.size = "custom";
|
||||
input.width = size.width;
|
||||
input.height = size.height;
|
||||
} else {
|
||||
input.size = size;
|
||||
}
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
} else if (referenceImages.length > 0 && family === "seedream45") {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildWanInput(
|
||||
family: "wan27image" | "wan27imagepro",
|
||||
prompt: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const input: Record<string, unknown> = {
|
||||
prompt,
|
||||
size: resolveWanSize(family, args),
|
||||
};
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
input.images = referenceImages;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
parseModelId(model);
|
||||
|
||||
if (args.n !== 1) {
|
||||
throw new Error("Replicate integration currently supports exactly one output image per request. Remove --n or use --n 1.");
|
||||
}
|
||||
|
||||
if (args.imageSize && args.imageSizeSource !== "config") {
|
||||
throw new Error("Replicate models in baoyu-image-gen do not use --imageSize. Use --quality, --ar, or --size instead.");
|
||||
}
|
||||
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "nano-banana") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate nano-banana supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
if (args.size) {
|
||||
getRequestedAspectRatio(model, args);
|
||||
getNanoBananaResolution(args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "seedream45") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate Seedream 4.5 supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
resolveSeedream45Size(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "seedream5lite") {
|
||||
if (args.referenceImages.length > 14) {
|
||||
throw new Error("Replicate Seedream 5 Lite supports at most 14 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
validateDocumentedAspectRatio(model, args.aspectRatio);
|
||||
}
|
||||
resolveSeedream5LiteSize(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "wan27image" || family === "wan27imagepro") {
|
||||
if (args.referenceImages.length > 9) {
|
||||
throw new Error("Replicate Wan 2.7 image models support at most 9 reference images.");
|
||||
}
|
||||
if (args.aspectRatio) {
|
||||
const parsed = parseAspectRatio(args.aspectRatio);
|
||||
if (!parsed) {
|
||||
throw new Error(`Replicate Wan aspect ratio must be in W:H format, got ${args.aspectRatio}.`);
|
||||
}
|
||||
}
|
||||
resolveWanSize(family, args);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExplicitAspectRatio = !!args.aspectRatio && args.aspectRatioSource !== "config";
|
||||
|
||||
if (args.referenceImages.length > 0 || hasExplicitAspectRatio || args.size) {
|
||||
throw new Error(
|
||||
`Replicate model ${model} is not in the baoyu-image-gen compatibility list. Supported families: google/nano-banana*, bytedance/seedream-4.5, bytedance/seedream-5-lite, wan-video/wan-2.7-image, wan-video/wan-2.7-image-pro.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultOutputExtension(model: string): ".png" {
|
||||
const _family = getModelFamily(model);
|
||||
return ".png";
|
||||
}
|
||||
|
||||
export function buildInput(
|
||||
model: string,
|
||||
prompt: string,
|
||||
args: CliArgs,
|
||||
referenceImages: string[],
|
||||
): Record<string, unknown> {
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "nano-banana") {
|
||||
return buildNanoBananaInput(prompt, model, args, referenceImages);
|
||||
}
|
||||
|
||||
if (family === "seedream45" || family === "seedream5lite") {
|
||||
return buildSeedreamInput(family, prompt, model, args, referenceImages);
|
||||
}
|
||||
|
||||
if (family === "wan27image" || family === "wan27imagepro") {
|
||||
return buildWanInput(family, prompt, args, referenceImages);
|
||||
}
|
||||
|
||||
return { prompt };
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(p: string): Promise<string> {
|
||||
const buf = await readFile(p);
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
@@ -150,6 +555,11 @@ export function extractOutputUrl(prediction: PredictionResponse): string {
|
||||
if (typeof output === "string") return output;
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
if (output.length !== 1) {
|
||||
throw new Error(
|
||||
`Replicate returned ${output.length} outputs, but baoyu-image-gen currently supports saving exactly one image per request.`
|
||||
);
|
||||
}
|
||||
const first = output[0];
|
||||
if (typeof first === "string") return first;
|
||||
}
|
||||
@@ -178,13 +588,14 @@ export async function generateImage(
|
||||
if (!apiToken) throw new Error("REPLICATE_API_TOKEN is required. Get one at https://replicate.com/account/api-tokens");
|
||||
|
||||
const parsedModel = parseModelId(model);
|
||||
validateArgs(model, args);
|
||||
|
||||
const refDataUrls: string[] = [];
|
||||
for (const refPath of args.referenceImages) {
|
||||
refDataUrls.push(await readImageAsDataUrl(refPath));
|
||||
}
|
||||
|
||||
const input = buildInput(prompt, args, refDataUrls);
|
||||
const input = buildInput(model, prompt, args, refDataUrls);
|
||||
|
||||
console.log(`Generating image with Replicate (${model})...`);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
imageApiDialect: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
|
||||
@@ -3,13 +3,14 @@ export type Provider =
|
||||
| "openai"
|
||||
| "openrouter"
|
||||
| "dashscope"
|
||||
| "zai"
|
||||
| "minimax"
|
||||
| "replicate"
|
||||
| "jimeng"
|
||||
| "seedream"
|
||||
| "azure"
|
||||
| "zai";
|
||||
| "azure";
|
||||
export type Quality = "normal" | "2k";
|
||||
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
|
||||
|
||||
export type CliArgs = {
|
||||
prompt: string | null;
|
||||
@@ -18,9 +19,12 @@ export type CliArgs = {
|
||||
provider: Provider | null;
|
||||
model: string | null;
|
||||
aspectRatio: string | null;
|
||||
aspectRatioSource?: "cli" | "task" | "config" | null;
|
||||
size: string | null;
|
||||
quality: Quality | null;
|
||||
imageSize: string | null;
|
||||
imageSizeSource?: "cli" | "task" | "config" | null;
|
||||
imageApiDialect: OpenAIImageApiDialect | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
batchFile: string | null;
|
||||
@@ -40,6 +44,7 @@ export type BatchTaskInput = {
|
||||
size?: string | null;
|
||||
quality?: Quality | null;
|
||||
imageSize?: "1K" | "2K" | "4K" | null;
|
||||
imageApiDialect?: OpenAIImageApiDialect | null;
|
||||
ref?: string[];
|
||||
n?: number;
|
||||
};
|
||||
@@ -57,17 +62,18 @@ export type ExtendConfig = {
|
||||
default_quality: Quality | null;
|
||||
default_aspect_ratio: string | null;
|
||||
default_image_size: "1K" | "2K" | "4K" | null;
|
||||
default_image_api_dialect: OpenAIImageApiDialect | null;
|
||||
default_model: {
|
||||
google: string | null;
|
||||
openai: string | null;
|
||||
openrouter: string | null;
|
||||
dashscope: string | null;
|
||||
zai: string | null;
|
||||
minimax: string | null;
|
||||
replicate: string | null;
|
||||
jimeng: string | null;
|
||||
seedream: string | null;
|
||||
azure: string | null;
|
||||
zai: string | null;
|
||||
};
|
||||
batch?: {
|
||||
max_workers?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user