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

View File

@ -573,11 +573,10 @@ function detectProvider(args: CliArgs): Provider {
args.provider !== "google" &&
args.provider !== "openai" &&
args.provider !== "openrouter" &&
args.provider !== "replicate" &&
args.provider !== "jimeng"
args.provider !== "replicate"
) {
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 (hasOpenrouter) return "openrouter";
if (hasReplicate) return "replicate";
if (hasJimeng) return "jimeng";
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."
);
}

View File

@ -1,6 +1,8 @@
import type { CliArgs } from "../types";
import * as crypto from "node:crypto";
type JimengSizePreset = "normal" | "2k" | "4k";
export function getDefaultModel(): string {
return process.env.JIMENG_IMAGE_MODEL || "jimeng_t2i_v40";
}
@ -17,6 +19,34 @@ function getRegion(): string {
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
* Following the official documentation at:
@ -30,11 +60,10 @@ function generateSignature(
accessKey: string,
secretKey: string,
region: string,
service: string
service: string,
canonicalUri: string
): string {
// 1. Create canonical request
const canonicalUri = "/";
// Sort query parameters alphabetically
const sortedQuery = Object.entries(query)
.sort(([a], [b]) => a.localeCompare(b))
@ -70,8 +99,10 @@ function generateSignature(
// 2. Create string to sign
const algorithm = "HMAC-SHA256";
const now = new Date();
const timestamp = now.toISOString().replace(/[:\-]|\.\d{3}/g, "");
const timestamp = headers["X-Date"] || headers["x-date"];
if (!timestamp) {
throw new Error("Jimeng signature generation requires an X-Date header.");
}
const dateStamp = timestamp.slice(0, 8);
const credentialScope = `${dateStamp}/${region}/${service}/request`;
@ -99,9 +130,7 @@ function generateSignature(
.digest("hex");
// 4. Create authorization header
const authorization = `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
return { authorization, timestamp };
return `${algorithm} Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}
/**
@ -144,29 +173,22 @@ const SIZE_PRESETS: Record<string, Record<string, string>> = {
},
};
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");
}
function normalizeDimensions(value: string): string | null {
const match = value.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
if (!match) return null;
return `${match[1]}x${match[2]}`;
}
// Default to 2K quality if not specified
const qualityLevel = quality === "normal" ? "normal" : "2k";
function getClosestPresetSize(ar: string | null, qualityLevel: JimengSizePreset): string {
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;
// 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;
@ -183,6 +205,25 @@ function getImageSize(ar: string | null, quality: CliArgs["quality"], imageSize?
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
*/
@ -194,13 +235,12 @@ async function submitTask(
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",
};
const endpoint = resolveEndpoint(query);
// Request body - Jimeng API expects width/height as separate integers
const [width, height] = size.split("x").map(Number);
@ -221,11 +261,11 @@ async function submitTask(
const headers = {
"Content-Type": "application/json",
"X-Date": timestampHeader,
"Host": "visual.volcengineapi.com",
"Host": endpoint.host,
};
// Generate signature
const { authorization, timestamp } = generateSignature(
const authorization = generateSignature(
"POST",
query,
headers,
@ -233,15 +273,13 @@ async function submitTask(
accessKey,
secretKey,
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 });
const res = await fetch(url, {
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
...headers,
@ -283,7 +321,6 @@ async function pollForResult(
secretKey: string,
region: string
): Promise<Uint8Array> {
const baseUrl = "https://visual.volcengineapi.com";
const maxAttempts = 60;
const pollIntervalMs = 2000;
@ -293,6 +330,7 @@ async function pollForResult(
Action: "CVSync2AsyncGetResult",
Version: "2022-08-31",
};
const endpoint = resolveEndpoint(query);
// Request body - include req_key and task_id
const bodyObj = {
@ -307,11 +345,11 @@ async function pollForResult(
const headers = {
"Content-Type": "application/json",
"X-Date": timestampHeader,
"Host": "visual.volcengineapi.com",
"Host": endpoint.host,
};
// Generate signature
const { authorization } = generateSignature(
const authorization = generateSignature(
"POST",
query,
headers,
@ -319,13 +357,11 @@ async function pollForResult(
accessKey,
secretKey,
region,
"cv"
"cv",
endpoint.canonicalUri
);
// Build URL with query parameters
const url = `${baseUrl}/?Action=${query.Action}&Version=${query.Version}`;
const res = await fetch(url, {
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
...headers,
@ -401,6 +437,12 @@ export async function generateImage(
model: string,
args: CliArgs
): 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 secretKey = getSecretKey();
const region = getRegion();