fix(image-gen): tighten Jimeng provider behavior

This commit is contained in:
Jim Liu 宝玉
2026-03-13 15:39:46 -05:00
parent c151f33775
commit cb17cb9cca
2 changed files with 84 additions and 44 deletions
+3 -5
View File
@@ -573,11 +573,10 @@ function detectProvider(args: CliArgs): Provider {
args.provider !== "google" && args.provider !== "google" &&
args.provider !== "openai" && args.provider !== "openai" &&
args.provider !== "openrouter" && args.provider !== "openrouter" &&
args.provider !== "replicate" && args.provider !== "replicate"
args.provider !== "jimeng"
) { ) {
throw new Error( throw new Error(
"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." "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), or --provider replicate."
); );
} }
@@ -596,9 +595,8 @@ function detectProvider(args: CliArgs): Provider {
if (hasOpenai) return "openai"; if (hasOpenai) return "openai";
if (hasOpenrouter) return "openrouter"; if (hasOpenrouter) return "openrouter";
if (hasReplicate) return "replicate"; if (hasReplicate) return "replicate";
if (hasJimeng) return "jimeng";
throw new Error( throw new Error(
"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." "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."
); );
} }
@@ -1,6 +1,8 @@
import type { CliArgs } from "../types"; import type { CliArgs } from "../types";
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
type JimengSizePreset = "normal" | "2k" | "4k";
export function getDefaultModel(): string { export function getDefaultModel(): string {
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40"; return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
} }
@@ -17,6 +19,34 @@ function getRegion(): string {
return process.env.JIMENG_REGION || "cn-north-1"; return process.env.JIMENG_REGION || "cn-north-1";
} }
function getBaseUrl(): string {
return process.env.JIMENG_BASE_URL || "https://visual.volcengineapi.com";
}
function resolveEndpoint(query: Record<string, string>): {
url: string;
host: string;
canonicalUri: string;
} {
let baseUrl: URL;
try {
baseUrl = new URL(getBaseUrl());
} catch {
throw new Error(`Invalid JIMENG_BASE_URL: ${getBaseUrl()}`);
}
baseUrl.search = "";
for (const [key, value] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
baseUrl.searchParams.set(key, value);
}
return {
url: baseUrl.toString(),
host: baseUrl.host,
canonicalUri: baseUrl.pathname || "/",
};
}
/** /**
* Volcengine HMAC-SHA256 signature generation * Volcengine HMAC-SHA256 signature generation
* Following the official documentation at: * Following the official documentation at:
@@ -30,11 +60,10 @@ function generateSignature(
accessKey: string, accessKey: string,
secretKey: string, secretKey: string,
region: string, region: string,
service: string service: string,
canonicalUri: string
): string { ): string {
// 1. Create canonical request // 1. Create canonical request
const canonicalUri = "/";
// Sort query parameters alphabetically // Sort query parameters alphabetically
const sortedQuery = Object.entries(query) const sortedQuery = Object.entries(query)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
@@ -70,8 +99,10 @@ function generateSignature(
// 2. Create string to sign // 2. Create string to sign
const algorithm = "HMAC-SHA256"; const algorithm = "HMAC-SHA256";
const now = new Date(); const timestamp = headers["X-Date"] || headers["x-date"];
const timestamp = now.toISOString().replace(/[:\-]|\.\d{3}/g, ""); if (!timestamp) {
throw new Error("Jimeng signature generation requires an X-Date header.");
}
const dateStamp = timestamp.slice(0, 8); const dateStamp = timestamp.slice(0, 8);
const credentialScope = `${dateStamp}/${region}/${service}/request`; const credentialScope = `${dateStamp}/${region}/${service}/request`;
@@ -99,9 +130,7 @@ function generateSignature(
.digest("hex"); .digest("hex");
// 4. Create authorization header // 4. Create authorization header
const authorization = `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
return { authorization, timestamp };
} }
/** /**
@@ -144,29 +173,22 @@ const SIZE_PRESETS: Record<string, Record<string, string>> = {
}, },
}; };
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string { function normalizeDimensions(value: string): string | null {
// If explicit size is provided, normalize it (replace * with x) const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (imageSize) { if (!match) return null;
return imageSize.replace("*", "x"); return `${match[1]}x${match[2]}`;
} }
// Default to 2K quality if not specified function getClosestPresetSize(ar: string | null, qualityLevel: JimengSizePreset): string {
const qualityLevel = quality === "normal" ? "normal" : "2k";
const presets = SIZE_PRESETS[qualityLevel]; const presets = SIZE_PRESETS[qualityLevel];
const defaultSize = presets["1:1"]!;
// Default size
const defaultSize = qualityLevel === "normal" ? "1024x1024" : "2048x2048";
// If no aspect ratio, return default
if (!ar) return defaultSize; if (!ar) return defaultSize;
// Parse aspect ratio and find closest match
const parsed = parseAspectRatio(ar); const parsed = parseAspectRatio(ar);
if (!parsed) return defaultSize; if (!parsed) return defaultSize;
const targetRatio = parsed.width / parsed.height; const targetRatio = parsed.width / parsed.height;
// Find closest aspect ratio in presets
let bestMatch = defaultSize; let bestMatch = defaultSize;
let bestDiff = Infinity; let bestDiff = Infinity;
@@ -183,6 +205,25 @@ function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?
return bestMatch; return bestMatch;
} }
function normalizeImageSizePreset(imageSize: string, ar: string | null): string | null {
const preset = imageSize.trim().toUpperCase();
if (preset === "1K") return getClosestPresetSize(ar, "normal");
if (preset === "2K") return getClosestPresetSize(ar, "2k");
if (preset === "4K") return getClosestPresetSize(ar, "4k");
return normalizeDimensions(imageSize);
}
function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
if (imageSize) {
const normalizedSize = normalizeImageSizePreset(imageSize, ar);
if (normalizedSize) return normalizedSize;
}
// Default to 2K quality if not specified
const qualityLevel: JimengSizePreset = quality === "normal" ? "normal" : "2k";
return getClosestPresetSize(ar, qualityLevel);
}
/** /**
* Step 1: Submit async task to Volcengine Jimeng API * Step 1: Submit async task to Volcengine Jimeng API
*/ */
@@ -194,13 +235,12 @@ async function submitTask(
secretKey: string, secretKey: string,
region: string region: string
): Promise<string> { ): Promise<string> {
const baseUrl = "https://visual.volcengineapi.com";
// Query parameters for submit endpoint // Query parameters for submit endpoint
const query = { const query = {
Action: "CVSync2AsyncSubmitTask", Action: "CVSync2AsyncSubmitTask",
Version: "2022-08-31", Version: "2022-08-31",
}; };
const endpoint = resolveEndpoint(query);
// Request body - Jimeng API expects width/height as separate integers // Request body - Jimeng API expects width/height as separate integers
const [width, height] = size.split("x").map(Number); const [width, height] = size.split("x").map(Number);
@@ -221,11 +261,11 @@ async function submitTask(
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Date": timestampHeader, "X-Date": timestampHeader,
"Host": "visual.volcengineapi.com", "Host": endpoint.host,
}; };
// Generate signature // Generate signature
const { authorization, timestamp } = generateSignature( const authorization = generateSignature(
"POST", "POST",
query, query,
headers, headers,
@@ -233,15 +273,13 @@ async function submitTask(
accessKey, accessKey,
secretKey, secretKey,
region, region,
"cv" "cv",
endpoint.canonicalUri
); );
// Build URL with query parameters
const url = `${baseUrl}/?Action=${query.Action}&Version=${query.Version}`;
console.error(`Submitting task to Jimeng (${model})...`, { width, height }); console.error(`Submitting task to Jimeng (${model})...`, { width, height });
const res = await fetch(url, { const res = await fetch(endpoint.url, {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...headers,
@@ -283,7 +321,6 @@ async function pollForResult(
secretKey: string, secretKey: string,
region: string region: string
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const baseUrl = "https://visual.volcengineapi.com";
const maxAttempts = 60; const maxAttempts = 60;
const pollIntervalMs = 2000; const pollIntervalMs = 2000;
@@ -293,6 +330,7 @@ async function pollForResult(
Action: "CVSync2AsyncGetResult", Action: "CVSync2AsyncGetResult",
Version: "2022-08-31", Version: "2022-08-31",
}; };
const endpoint = resolveEndpoint(query);
// Request body - include req_key and task_id // Request body - include req_key and task_id
const bodyObj = { const bodyObj = {
@@ -307,11 +345,11 @@ async function pollForResult(
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Date": timestampHeader, "X-Date": timestampHeader,
"Host": "visual.volcengineapi.com", "Host": endpoint.host,
}; };
// Generate signature // Generate signature
const { authorization } = generateSignature( const authorization = generateSignature(
"POST", "POST",
query, query,
headers, headers,
@@ -319,13 +357,11 @@ async function pollForResult(
accessKey, accessKey,
secretKey, secretKey,
region, region,
"cv" "cv",
endpoint.canonicalUri
); );
// Build URL with query parameters const res = await fetch(endpoint.url, {
const url = `${baseUrl}/?Action=${query.Action}&Version=${query.Version}`;
const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
...headers, ...headers,
@@ -401,6 +437,12 @@ export async function generateImage(
model: string, model: string,
args: CliArgs args: CliArgs
): Promise<Uint8Array> { ): Promise<Uint8Array> {
if (args.referenceImages.length > 0) {
throw new Error(
"Jimeng does not support reference images. Use --provider google, openai, openrouter, or replicate."
);
}
const accessKey = getAccessKey(); const accessKey = getAccessKey();
const secretKey = getSecretKey(); const secretKey = getSecretKey();
const region = getRegion(); const region = getRegion();