Merge pull request #87 from lindaifeng/main
Integrate DreamGraph and DoubaoGraph
This commit is contained in:
commit
88d6e09472
|
|
@ -55,6 +55,8 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
|||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||
};
|
||||
|
||||
function printUsage(): void {
|
||||
|
|
@ -69,7 +71,7 @@ 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|replicate Force provider (auto-detect by default)
|
||||
--provider google|openai|openrouter|dashscope|replicate|jimeng|seedream 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)
|
||||
|
|
@ -107,11 +109,16 @@ Environment variables:
|
|||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||
DASHSCOPE_API_KEY DashScope 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
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
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 (z-image-turbo)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||
OPENAI_BASE_URL Custom OpenAI endpoint
|
||||
OPENAI_IMAGE_USE_CHAT Use /chat/completions instead of /images/generations (true|false)
|
||||
OPENROUTER_BASE_URL Custom OpenRouter endpoint
|
||||
|
|
@ -120,6 +127,8 @@ Environment variables:
|
|||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||
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
|
||||
|
|
@ -217,7 +226,9 @@ function parseArgs(argv: string[]): CliArgs {
|
|||
v !== "openai" &&
|
||||
v !== "openrouter" &&
|
||||
v !== "dashscope" &&
|
||||
v !== "replicate"
|
||||
v !== "replicate" &&
|
||||
v !== "jimeng" &&
|
||||
v !== "seedream"
|
||||
) {
|
||||
throw new Error(`Invalid provider: ${v}`);
|
||||
}
|
||||
|
|
@ -370,6 +381,8 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||
openrouter: null,
|
||||
dashscope: null,
|
||||
replicate: null,
|
||||
jimeng: null,
|
||||
seedream: null,
|
||||
};
|
||||
currentKey = "default_model";
|
||||
currentProvider = null;
|
||||
|
|
@ -393,7 +406,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "replicate"
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
)
|
||||
) {
|
||||
config.batch ??= {};
|
||||
|
|
@ -407,7 +422,9 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||
key === "openai" ||
|
||||
key === "openrouter" ||
|
||||
key === "dashscope" ||
|
||||
key === "replicate"
|
||||
key === "replicate" ||
|
||||
key === "jimeng" ||
|
||||
key === "seedream"
|
||||
)
|
||||
) {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
|
|
@ -498,9 +515,11 @@ function getConfiguredProviderRateLimits(
|
|||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||
};
|
||||
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||
configured[provider] = {
|
||||
|
|
@ -554,10 +573,11 @@ function detectProvider(args: CliArgs): Provider {
|
|||
args.provider !== "google" &&
|
||||
args.provider !== "openai" &&
|
||||
args.provider !== "openrouter" &&
|
||||
args.provider !== "replicate"
|
||||
args.provider !== "replicate" &&
|
||||
args.provider !== "jimeng"
|
||||
) {
|
||||
throw new Error(
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), or --provider replicate."
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), --provider replicate, or --provider jimeng."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -568,14 +588,17 @@ function detectProvider(args: CliArgs): Provider {
|
|||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||
const hasDashscope = !!process.env.DASHSCOPE_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;
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
if (hasOpenrouter) return "openrouter";
|
||||
if (hasReplicate) return "replicate";
|
||||
if (hasJimeng) return "jimeng";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI, OpenRouter or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
"Reference images require Google, OpenAI, OpenRouter, Replicate or Jimeng. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, or Jimeng keys, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -585,13 +608,15 @@ function detectProvider(args: CliArgs): Provider {
|
|||
hasOpenrouter && "openrouter",
|
||||
hasDashscope && "dashscope",
|
||||
hasReplicate && "replicate",
|
||||
hasJimeng && "jimeng",
|
||||
hasSeedream && "seedream",
|
||||
].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, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, or REPLICATE_API_TOKEN.\n" +
|
||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||
);
|
||||
}
|
||||
|
|
@ -632,6 +657,8 @@ async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
|||
if (provider === "dashscope") return (await import("./providers/dashscope")) 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;
|
||||
return (await import("./providers/openai")) as ProviderModule;
|
||||
}
|
||||
|
||||
|
|
@ -658,6 +685,8 @@ function getModelForProvider(
|
|||
}
|
||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||
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;
|
||||
}
|
||||
return providerModule.getDefaultModel();
|
||||
}
|
||||
|
|
@ -873,7 +902,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", "dashscope"] as Provider[]) {
|
||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
||||
const limit = providerRateLimits[provider];
|
||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,425 @@
|
|||
import type { CliArgs } from "../types";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
|
||||
}
|
||||
|
||||
function getAccessKey(): string | null {
|
||||
return process.env.JIMENG_ACCESS_KEY_ID || null;
|
||||
}
|
||||
|
||||
function getSecretKey(): string | null {
|
||||
return process.env.JIMENG_SECRET_ACCESS_KEY || null;
|
||||
}
|
||||
|
||||
function getRegion(): string {
|
||||
return process.env.JIMENG_REGION || "cn-north-1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Volcengine HMAC-SHA256 signature generation
|
||||
* Following the official documentation at:
|
||||
* https://www.volcengine.com/docs/85621/1817045
|
||||
*/
|
||||
function generateSignature(
|
||||
method: string,
|
||||
query: Record<string, string>,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string,
|
||||
service: string
|
||||
): string {
|
||||
// 1. Create canonical request
|
||||
const canonicalUri = "/";
|
||||
|
||||
// Sort query parameters alphabetically
|
||||
const sortedQuery = Object.entries(query)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
|
||||
// Sort headers alphabetically and create canonical headers
|
||||
const sortedHeaders = Object.entries(headers)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k.toLowerCase()}:${v.trim()}\n`)
|
||||
.join("");
|
||||
|
||||
const signedHeaders = Object.keys(headers)
|
||||
.sort()
|
||||
.map(k => k.toLowerCase())
|
||||
.join(";");
|
||||
|
||||
const hashedPayload = crypto.createHash("sha256").update(body, "utf8").digest("hex");
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
sortedQuery,
|
||||
sortedHeaders,
|
||||
signedHeaders,
|
||||
hashedPayload,
|
||||
].join("\n");
|
||||
|
||||
const hashedCanonicalRequest = crypto
|
||||
.createHash("sha256")
|
||||
.update(canonicalRequest, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
// 2. Create string to sign
|
||||
const algorithm = "HMAC-SHA256";
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const dateStamp = timestamp.slice(0, 8);
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/request`;
|
||||
|
||||
const stringToSign = [
|
||||
algorithm,
|
||||
timestamp,
|
||||
credentialScope,
|
||||
hashedCanonicalRequest,
|
||||
].join("\n");
|
||||
|
||||
// 3. Calculate signature
|
||||
const kDate = crypto
|
||||
.createHmac("sha256", secretKey)
|
||||
.update(dateStamp)
|
||||
.digest();
|
||||
|
||||
const kRegion = crypto.createHmac("sha256", kDate).update(region).digest();
|
||||
const kService = crypto.createHmac("sha256", kRegion).update(service).digest();
|
||||
const kSigning = crypto.createHmac("sha256", kService).update("request").digest();
|
||||
|
||||
const signature = crypto
|
||||
.createHmac("sha256", kSigning)
|
||||
.update(stringToSign)
|
||||
.digest("hex");
|
||||
|
||||
// 4. Create authorization header
|
||||
const authorization = `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return { authorization, timestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse aspect ratio string like "16:9", "1:1", "4:3" into width and height
|
||||
*/
|
||||
function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported size presets for different quality levels
|
||||
* Based on Volcengine Jimeng documentation
|
||||
*/
|
||||
const SIZE_PRESETS: Record<string, Record<string, string>> = {
|
||||
normal: {
|
||||
"1:1": "1024x1024",
|
||||
"4:3": "1360x1020",
|
||||
"16:9": "1536x864",
|
||||
"3:2": "1440x960",
|
||||
"21:9": "1920x824",
|
||||
},
|
||||
"2k": {
|
||||
"1:1": "2048x2048",
|
||||
"4:3": "2304x1728",
|
||||
"16:9": "2560x1440",
|
||||
"3:2": "2496x1664",
|
||||
"21:9": "3024x1296",
|
||||
},
|
||||
"4k": {
|
||||
"1:1": "4096x4096",
|
||||
"4:3": "4694x3520",
|
||||
"16:9": "5404x3040",
|
||||
"3:2": "4992x3328",
|
||||
"21:9": "6198x2656",
|
||||
},
|
||||
};
|
||||
|
||||
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
// If explicit size is provided, normalize it (replace * with x)
|
||||
if (imageSize) {
|
||||
return imageSize.replace("*", "x");
|
||||
}
|
||||
|
||||
// Default to 2K quality if not specified
|
||||
const qualityLevel = quality === "normal" ? "normal" : "2k";
|
||||
const presets = SIZE_PRESETS[qualityLevel];
|
||||
|
||||
// Default size
|
||||
const defaultSize = qualityLevel === "normal" ? "1024x1024" : "2048x2048";
|
||||
|
||||
// If no aspect ratio, return default
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
// Parse aspect ratio and find closest match
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
|
||||
// Find closest aspect ratio in presets
|
||||
let bestMatch = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [ratio, size] of Object.entries(presets)) {
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const presetRatio = w / h;
|
||||
const diff = Math.abs(presetRatio - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestMatch = size;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Submit async task to Volcengine Jimeng API
|
||||
*/
|
||||
async function submitTask(
|
||||
prompt: string,
|
||||
model: string,
|
||||
size: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string
|
||||
): Promise<string> {
|
||||
const baseUrl = "https://visual.volcengineapi.com";
|
||||
|
||||
// Query parameters for submit endpoint
|
||||
const query = {
|
||||
Action: "CVSync2AsyncSubmitTask",
|
||||
Version: "2022-08-31",
|
||||
};
|
||||
|
||||
// Request body - Jimeng API expects width/height as separate integers
|
||||
const [width, height] = size.split("x").map(Number);
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
prompt_text: prompt,
|
||||
// Use separate width and height parameters instead of size string
|
||||
width: width,
|
||||
height: height,
|
||||
// Optional: seed for reproducibility
|
||||
// seed: Math.floor(Math.random() * 999999),
|
||||
};
|
||||
|
||||
const body = JSON.stringify(bodyObj);
|
||||
|
||||
// Headers
|
||||
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Date": timestampHeader,
|
||||
"Host": "visual.volcengineapi.com",
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const { authorization, timestamp } = generateSignature(
|
||||
"POST",
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
"cv"
|
||||
);
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = `${baseUrl}/?Action=${query.Action}&Version=${query.Version}`;
|
||||
|
||||
console.error(`Submitting task to Jimeng (${model})...`, { width, height });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Jimeng API submit error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
task_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Volcengine API returns code 10000 for success
|
||||
if (result.code !== 10000 || !result.data?.task_id) {
|
||||
console.error("Submit response:", JSON.stringify(result, null, 2));
|
||||
throw new Error(`Failed to submit task: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
return result.data.task_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Poll for task result
|
||||
* Returns image data directly as Uint8Array
|
||||
*/
|
||||
async function pollForResult(
|
||||
taskId: string,
|
||||
model: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
region: string
|
||||
): Promise<Uint8Array> {
|
||||
const baseUrl = "https://visual.volcengineapi.com";
|
||||
const maxAttempts = 60;
|
||||
const pollIntervalMs = 2000;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// Query parameters for result endpoint
|
||||
const query = {
|
||||
Action: "CVSync2AsyncGetResult",
|
||||
Version: "2022-08-31",
|
||||
};
|
||||
|
||||
// Request body - include req_key and task_id
|
||||
const bodyObj = {
|
||||
req_key: model,
|
||||
task_id: taskId,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(bodyObj);
|
||||
|
||||
// Headers
|
||||
const timestampHeader = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, "");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Date": timestampHeader,
|
||||
"Host": "visual.volcengineapi.com",
|
||||
};
|
||||
|
||||
// Generate signature
|
||||
const { authorization } = generateSignature(
|
||||
"POST",
|
||||
query,
|
||||
headers,
|
||||
body,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
"cv"
|
||||
);
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = `${baseUrl}/?Action=${query.Action}&Version=${query.Version}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Authorization": authorization,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Jimeng API poll error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
status?: string;
|
||||
image_urls?: string[];
|
||||
binary_data_base64?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
// Volcengine API returns code 10000 for success
|
||||
if (result.code === 10000 && result.data) {
|
||||
const { status, image_urls, binary_data_base64 } = result.data;
|
||||
|
||||
// Check for base64 image data (preferred by Jimeng)
|
||||
if (binary_data_base64 && binary_data_base64.length > 0) {
|
||||
console.error("Image received as base64 data");
|
||||
const base64Data = binary_data_base64[0]!;
|
||||
// Convert base64 to Uint8Array
|
||||
const binaryString = Buffer.from(base64Data, "base64").toString("binary");
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Fallback to URL format
|
||||
if (status === "done" && image_urls && image_urls.length > 0) {
|
||||
// Download from URL
|
||||
console.error(`Downloading image from ${image_urls[0]}...`);
|
||||
const imgRes = await fetch(image_urls[0]!);
|
||||
if (!imgRes.ok) {
|
||||
throw new Error(`Failed to download image from ${image_urls[0]}`);
|
||||
}
|
||||
const buffer = await imgRes.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
if (status === "in_queue" || status === "generating") {
|
||||
console.error(`Task status: ${status} (${attempt + 1}/${maxAttempts})`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "fail") {
|
||||
throw new Error(`Jimeng task failed: ${result.message || "Generation failed"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Poll response:", JSON.stringify(result, null, 2));
|
||||
throw new Error(`Unexpected response during polling: ${result.message || "Unknown error"}`);
|
||||
}
|
||||
|
||||
throw new Error("Task timeout: image generation took too long");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const accessKey = getAccessKey();
|
||||
const secretKey = getSecretKey();
|
||||
const region = getRegion();
|
||||
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
"JIMENG_ACCESS_KEY_ID and JIMENG_SECRET_ACCESS_KEY are required. " +
|
||||
"Get your credentials from https://console.volcengine.com/iam/keymanage"
|
||||
);
|
||||
}
|
||||
|
||||
const size = getImageSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
// Step 1: Submit task
|
||||
const taskId = await submitTask(prompt, model, size, accessKey, secretKey, region);
|
||||
|
||||
// Step 2: Poll for result (returns image data directly)
|
||||
const imageData = await pollForResult(taskId, model, accessKey, secretKey, region);
|
||||
|
||||
console.error("Image generation complete!");
|
||||
return imageData;
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import type { CliArgs } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.ARK_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert aspect ratio to Seedream size format
|
||||
* Seedream API accepts: "2k" (default), "3k", or WIDTHxHEIGHT format
|
||||
* Note: API uses lowercase "2k"/"3k", not "2K"/"3K"
|
||||
*/
|
||||
function getSeedreamSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
// If explicit size is provided
|
||||
if (imageSize) {
|
||||
const upper = imageSize.toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper.toLowerCase(); // API expects "2k" or "3k"
|
||||
}
|
||||
// For widthxheight format, pass through as-is
|
||||
if (imageSize.includes("x")) {
|
||||
return imageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2k (smallest option supported by API)
|
||||
return "2k";
|
||||
}
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model: string;
|
||||
created: number;
|
||||
data: Array<{
|
||||
url: string;
|
||||
size: string;
|
||||
}>;
|
||||
usage: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"ARK_API_KEY is required. " +
|
||||
"Get your API key from https://console.volcengine.com/ark"
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const size = getSeedreamSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
|
||||
console.error(`Calling Seedream API (${model}) with size: ${size}`);
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
output_format: "png",
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as SeedreamImageResponse;
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
}
|
||||
|
||||
const imageUrl = result.data[0].url;
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image URL in Seedream response");
|
||||
}
|
||||
|
||||
// Download image from URL
|
||||
console.error(`Downloading image from ${imageUrl}...`);
|
||||
const imgResponse = await fetch(imageUrl);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${imageUrl}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate";
|
||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream";
|
||||
export type Quality = "normal" | "2k";
|
||||
|
||||
export type CliArgs = {
|
||||
|
|
@ -53,6 +53,8 @@ export type ExtendConfig = {
|
|||
openrouter: string | null;
|
||||
dashscope: string | null;
|
||||
replicate: string | null;
|
||||
jimeng: string | null;
|
||||
seedream: string | null;
|
||||
};
|
||||
batch?: {
|
||||
max_workers?: number | null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue