326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import type { EnabledImageCapability, GenerationStatus } from "@/lib/types";
|
|
import { imageDimensionsForEngine } from "@/lib/image-size-presets";
|
|
import { isImageModel } from "@/lib/image-models";
|
|
|
|
export type ImageCreationEngine = "jimeng" | "evolink" | "bailian";
|
|
|
|
export const EVOLINK_PLATFORM_RESOLUTION = "1K" as const;
|
|
|
|
export type EvolinkImageSettings = {
|
|
apiKey?: string;
|
|
baseUrl: string;
|
|
model: string;
|
|
quality?: string;
|
|
};
|
|
|
|
export type EvolinkImageConfig = EvolinkImageSettings & {
|
|
apiKey: string;
|
|
};
|
|
|
|
export type EvolinkTaskResponse = {
|
|
id?: string;
|
|
task_id?: string;
|
|
status?: string;
|
|
results?: unknown;
|
|
data?: unknown;
|
|
error?: {
|
|
code?: string | number;
|
|
message?: string;
|
|
} | null;
|
|
message?: string;
|
|
};
|
|
|
|
export function getSelectedImageEngine(): ImageCreationEngine {
|
|
const value = (process.env.IMAGE_CREATION_ENGINE || process.env.IMAGE_PROVIDER || "jimeng").trim().toLowerCase();
|
|
return value === "evolink" || value === "bailian" ? value : "jimeng";
|
|
}
|
|
|
|
export function getEffectiveImageEngine(capability: EnabledImageCapability, override?: unknown): ImageCreationEngine {
|
|
const overrideEngine = normalizeImageEngine(override);
|
|
if (overrideEngine) return overrideEngine;
|
|
if (capability === "image.generate") return selectedEngineFrom(process.env.IMAGE_GENERATE_ENGINE);
|
|
return getSelectedImageEngine();
|
|
}
|
|
|
|
export function normalizeImageEngine(value: unknown): ImageCreationEngine | undefined {
|
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
if (normalized === "evolink") return "evolink";
|
|
if (normalized === "bailian") return "bailian";
|
|
if (normalized === "jimeng") return "jimeng";
|
|
return undefined;
|
|
}
|
|
|
|
function selectedEngineFrom(value: string | undefined): ImageCreationEngine {
|
|
const normalized = (value || "").trim().toLowerCase();
|
|
if (normalized === "evolink") return "evolink";
|
|
if (normalized === "bailian") return "bailian";
|
|
if (normalized === "jimeng") return "jimeng";
|
|
return getSelectedImageEngine();
|
|
}
|
|
|
|
export function getEvolinkImageSettings(): EvolinkImageSettings {
|
|
return {
|
|
apiKey: process.env.EVOLINK_API_KEY?.trim() || undefined,
|
|
baseUrl: (process.env.EVOLINK_BASE_URL || "https://api.evolink.ai").replace(/\/+$/, ""),
|
|
model: process.env.EVOLINK_IMAGE_MODEL || "gpt-image-2",
|
|
quality: cleanOptional(process.env.EVOLINK_IMAGE_QUALITY)
|
|
};
|
|
}
|
|
|
|
export function getEvolinkImageConfig(): EvolinkImageConfig | null {
|
|
const settings = getEvolinkImageSettings();
|
|
if (!settings.apiKey) return null;
|
|
return { ...settings, apiKey: settings.apiKey };
|
|
}
|
|
|
|
export function buildEvolinkImagePayload(
|
|
capability: EnabledImageCapability,
|
|
input: Record<string, unknown>,
|
|
settings = getEvolinkImageSettings()
|
|
): Record<string, unknown> {
|
|
const prompt = String(input.prompt || "").trim();
|
|
const imageUrls = asStringArray(input.imageUrls);
|
|
const nested = asRecord(input.settings) || {};
|
|
const requestedModel = input.model;
|
|
if (requestedModel !== undefined && !isImageModel(requestedModel) && requestedModel !== settings.model) {
|
|
throw new Error("Unsupported EvoLink image model.");
|
|
}
|
|
const model = requestedModel || settings.model;
|
|
const image25 = model === "gpt-image-2.5-flare" || model === "gpt-image-2.5-sunburst";
|
|
if (image25) validateImage25Input(input, nested, imageUrls);
|
|
const payload: Record<string, unknown> = {
|
|
model,
|
|
prompt,
|
|
n: 1
|
|
};
|
|
|
|
if (!payload.prompt) throw new Error("Prompt is required for image generation.");
|
|
const rawQuality = input.quality ?? nested.quality;
|
|
const requestedQuality = cleanOptional(typeof rawQuality === "string" ? rawQuality : undefined)?.toLowerCase();
|
|
if (image25 && rawQuality !== undefined && (!requestedQuality || !validQuality(requestedQuality))) {
|
|
throw new Error("Unsupported EvoLink image quality.");
|
|
}
|
|
const quality = requestedQuality || cleanOptional(settings.quality)?.toLowerCase() || (image25 ? "medium" : undefined);
|
|
if (image25 && !validQuality(quality)) throw new Error("Unsupported EvoLink image quality.");
|
|
if (quality) payload.quality = quality;
|
|
payload.resolution = EVOLINK_PLATFORM_RESOLUTION;
|
|
assignSize(payload, {
|
|
size: input.size ?? nested.size,
|
|
width: input.width ?? nested.width,
|
|
height: input.height ?? nested.height
|
|
});
|
|
if (image25 && payload.size === undefined) payload.size = "1:1";
|
|
|
|
if (imageUrls.length) payload.image_urls = imageUrls;
|
|
return payload;
|
|
}
|
|
|
|
export async function submitEvolinkImageTask(
|
|
payload: Record<string, unknown>,
|
|
config = getEvolinkImageConfig()
|
|
): Promise<EvolinkTaskResponse> {
|
|
if (!config) throw new Error("EvoLink API key is not configured.");
|
|
return callEvolinkApi<EvolinkTaskResponse>("/v1/images/generations", config, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
|
|
export async function queryEvolinkTask(
|
|
taskId: string,
|
|
config = getEvolinkImageConfig()
|
|
): Promise<EvolinkTaskResponse> {
|
|
if (!config) throw new Error("EvoLink API key is not configured.");
|
|
return callEvolinkApi<EvolinkTaskResponse>(`/v1/tasks/${encodeURIComponent(taskId)}`, config, {
|
|
method: "GET"
|
|
});
|
|
}
|
|
|
|
export function getEvolinkTaskId(response: EvolinkTaskResponse): string | undefined {
|
|
const data = asRecord(response.data);
|
|
return firstString(response.id, response.task_id, data?.id, data?.task_id);
|
|
}
|
|
|
|
export function mapEvolinkStatus(response: EvolinkTaskResponse): GenerationStatus {
|
|
const status = String(response.status || asRecord(response.data)?.status || "").toLowerCase();
|
|
if (["completed", "complete", "succeeded", "success", "done"].includes(status)) return "succeeded";
|
|
if (["running", "processing", "generating", "in_progress"].includes(status)) return "running";
|
|
if (["queued", "pending", "created", "waiting", "in_queue"].includes(status)) return "queued";
|
|
if (["expired", "not_found"].includes(status)) return "expired";
|
|
if (["cancelled", "canceled"].includes(status)) return "cancelled";
|
|
if (["failed", "error"].includes(status)) return "failed";
|
|
return "running";
|
|
}
|
|
|
|
export function extractEvolinkResultUrls(response: EvolinkTaskResponse): string[] {
|
|
const data = asRecord(response.data);
|
|
const candidates = [
|
|
response.results,
|
|
data?.results,
|
|
data?.images,
|
|
data?.image_urls,
|
|
data?.output,
|
|
data?.outputs
|
|
];
|
|
const urls = new Set<string>();
|
|
for (const candidate of candidates) collectUrls(candidate, urls);
|
|
return [...urls];
|
|
}
|
|
|
|
function cleanOptional(value: string | undefined): string | undefined {
|
|
const normalized = value?.trim();
|
|
return normalized || undefined;
|
|
}
|
|
|
|
function validQuality(value: unknown): value is "low" | "medium" | "high" {
|
|
return value === "low" || value === "medium" || value === "high";
|
|
}
|
|
|
|
function validateImage25Input(input: Record<string, unknown>, nested: Record<string, unknown>, imageUrls: string[]) {
|
|
if (imageUrls.length > 16) throw new Error("EvoLink Image 2.5 supports at most 16 reference images.");
|
|
for (const resolution of [input.resolution, nested.resolution]) {
|
|
if (resolution !== undefined && (typeof resolution !== "string" || resolution.trim().toUpperCase() !== "1K")) {
|
|
throw new Error("EvoLink Image 2.5 resolution must be 1K.");
|
|
}
|
|
}
|
|
for (const key of ["n", "imageCount"]) {
|
|
for (const value of [input[key], nested[key]]) {
|
|
if (value !== undefined && !isOne(value)) throw new Error(`EvoLink Image 2.5 ${key} must be 1.`);
|
|
}
|
|
}
|
|
if (input.force_single === false || nested.force_single === false) throw new Error("EvoLink Image 2.5 requires force_single.");
|
|
for (const size of [input.size, nested.size]) {
|
|
if (size !== undefined && (typeof size !== "string" || !supportedImage25Sizes.has(size.trim()))) {
|
|
throw new Error("EvoLink Image 2.5 size must be a supported 1K aspect ratio or A4 size.");
|
|
}
|
|
}
|
|
const width = input.width ?? nested.width;
|
|
const height = input.height ?? nested.height;
|
|
if ((width === undefined) !== (height === undefined)) throw new Error("EvoLink Image 2.5 requires both width and height.");
|
|
if (width !== undefined) {
|
|
const w = asPositiveInteger(width);
|
|
const h = asPositiveInteger(height);
|
|
if (!w || !h || w > 8192 || h > 8192) throw new Error("Invalid EvoLink Image 2.5 dimensions.");
|
|
const mapped = imageDimensionsForEngine({ width: w, height: h }, "evolink");
|
|
const ratio = ratioFromDimensions(mapped.width, mapped.height);
|
|
const a4 = (mapped.width === 848 && mapped.height === 1200) || (mapped.width === 1200 && mapped.height === 848);
|
|
const pixels = w * h;
|
|
const custom = w % 16 === 0 && h % 16 === 0 && pixels >= 655360 && pixels <= 1048576 && Math.max(w, h) / Math.min(w, h) <= 3;
|
|
if (!ratio && !a4 && !custom) throw new Error("EvoLink Image 2.5 custom dimensions exceed the supported 1K size.");
|
|
}
|
|
}
|
|
|
|
function isOne(value: unknown): boolean {
|
|
return (typeof value === "number" && Number.isFinite(value) || typeof value === "string" && value.trim() !== "")
|
|
&& Number(value) === 1;
|
|
}
|
|
|
|
function assignSize(payload: Record<string, unknown>, input: Record<string, unknown>) {
|
|
if (typeof input.size === "string" && input.size.trim()) {
|
|
payload.size = input.size.trim();
|
|
return;
|
|
}
|
|
const width = asPositiveInteger(input.width);
|
|
const height = asPositiveInteger(input.height);
|
|
if (!width || !height) return;
|
|
const dimensions = imageDimensionsForEngine({ width, height }, "evolink");
|
|
payload.size = ratioFromDimensions(dimensions.width, dimensions.height) || `${dimensions.width}x${dimensions.height}`;
|
|
}
|
|
|
|
function asStringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map(String).map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function asPositiveInteger(value: unknown): number | undefined {
|
|
if (typeof value !== "number" && (typeof value !== "string" || value.trim() === "")) return undefined;
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
|
|
return parsed;
|
|
}
|
|
|
|
function firstString(...values: unknown[]): string | undefined {
|
|
for (const value of values) {
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function ratioFromDimensions(width: number, height: number): string | undefined {
|
|
const divisor = gcd(width, height);
|
|
const ratio = `${width / divisor}:${height / divisor}`;
|
|
return supportedRatios.has(ratio) ? ratio : undefined;
|
|
}
|
|
|
|
function gcd(a: number, b: number): number {
|
|
return b === 0 ? a : gcd(b, a % b);
|
|
}
|
|
|
|
const supportedRatios = new Set([
|
|
"1:1",
|
|
"1:2",
|
|
"2:1",
|
|
"1:3",
|
|
"3:1",
|
|
"2:3",
|
|
"3:2",
|
|
"3:4",
|
|
"4:3",
|
|
"4:5",
|
|
"5:4",
|
|
"9:16",
|
|
"16:9",
|
|
"9:21",
|
|
"21:9"
|
|
]);
|
|
const supportedImage25Sizes = new Set([...supportedRatios, "848x1200", "1200x848"]);
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: undefined;
|
|
}
|
|
|
|
function collectUrls(value: unknown, urls: Set<string>) {
|
|
if (!value) return;
|
|
if (typeof value === "string") {
|
|
if (/^https?:\/\//.test(value)) urls.add(value);
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectUrls(item, urls);
|
|
return;
|
|
}
|
|
const record = asRecord(value);
|
|
if (!record) return;
|
|
for (const key of ["url", "image_url", "imageUrl", "result_url", "resultUrl"]) {
|
|
collectUrls(record[key], urls);
|
|
}
|
|
}
|
|
|
|
async function callEvolinkApi<T>(
|
|
path: string,
|
|
config: EvolinkImageConfig,
|
|
init: RequestInit
|
|
): Promise<T> {
|
|
const headers = new Headers(init.headers);
|
|
headers.set("Content-Type", "application/json");
|
|
headers.set("Authorization", `Bearer ${config.apiKey}`);
|
|
const response = await fetch(`${config.baseUrl}${path}`, {
|
|
...init,
|
|
headers
|
|
});
|
|
const text = await response.text();
|
|
let json: unknown;
|
|
try {
|
|
json = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
throw new Error(`EvoLink returned non-JSON response: ${response.status} ${text.slice(0, 240)}`);
|
|
}
|
|
if (!response.ok) {
|
|
const message = typeof json === "object" && json && "message" in json ? String(json.message) : text;
|
|
throw new Error(`EvoLink HTTP ${response.status}: ${message}`);
|
|
}
|
|
return json as T;
|
|
}
|