2026-06-13 13:33:02 +08:00

1218 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import sharp from "sharp";
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
import {
getKreaModelPresetById,
inferKreaModelKind,
type KreaModelKind,
} from "@/lib/ai/krea-models";
import { uploadFile } from "@/lib/storage/service";
interface KreaConfig {
apiKey: string;
baseURL: string;
}
interface KreaImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
seed?: number;
negativePrompt?: string;
promptExtend?: boolean;
}
interface KreaVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
ratio?: string;
duration?: number;
resolution?: string;
seed?: number;
generateAudio?: boolean;
}
interface UploadedImage {
thumbnail: string;
original: string;
}
const DEFAULT_BASE_URL = "https://api.krea.ai";
const IMAGE_POLL_INTERVAL_MS = 2000;
const VIDEO_POLL_INTERVAL_MS = 5000;
const IMAGE_TIMEOUT_MS = 5 * 60 * 1000;
const VIDEO_TIMEOUT_MS = 20 * 60 * 1000;
const KREA_IMAGE_ASPECT_RATIOS = [
"21:9",
"16:9",
"9:16",
"4:3",
"3:4",
"3:2",
"2:3",
"5:4",
"4:5",
"1:1",
"auto",
] as const;
const KREA_OFFICIAL_IMAGE_ASPECT_RATIOS = KREA_IMAGE_ASPECT_RATIOS.filter(
(ratio) => ratio !== "auto"
);
function normalizeText(value?: string | null) {
return String(value || "").replace(/\u00a0/g, " ").trim().normalize("NFKC");
}
function normalizeBaseURL(baseURL: string) {
const normalized = normalizeText(baseURL).replace(/\/+$/, "");
return normalized || DEFAULT_BASE_URL;
}
function normalizeKreaApiKey(apiKey: string) {
const stripped = normalizeText(apiKey)
.replace(/^bearer\s+/i, "")
.replace(/^key\s+/i, "")
.replace(/^(?:krea[_-]?api[_-]?key|api[_-]?key|token)\s*[::]\s*/i, "");
return stripped.trim().replace(/[^\x20-\x7E]/g, "");
}
function buildHeaders(apiKey: string) {
return {
Authorization: `Bearer ${normalizeKreaApiKey(apiKey)}`,
"Content-Type": "application/json",
Accept: "application/json",
};
}
function joinEndpoint(baseURL: string, path: string) {
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${normalizeBaseURL(baseURL)}${cleanPath}`;
}
function resolveKreaEndpoint(modelId: string) {
const normalized = normalizeText(modelId);
if (!normalized) {
throw new Error("Krea 模型 ID 不能为空");
}
if (normalized.startsWith("/generate/")) return normalized;
if (normalized.startsWith("generate/")) return `/${normalized}`;
if (/^(image|video|enhance)\//i.test(normalized)) return `/generate/${normalized}`;
throw new Error("Krea 模型 ID 需要使用官方 /generate/... endpoint");
}
function detectExtensionFromContentType(contentType?: string | null) {
const normalized = String(contentType || "").toLowerCase();
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
if (normalized.includes("png")) return "png";
if (normalized.includes("mp4")) return "mp4";
if (normalized.includes("quicktime")) return "mov";
if (normalized.includes("webm")) return "webm";
return "bin";
}
function generateFilename(prefix: string, extension: string) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
function extractDataUriParts(dataUri: string) {
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
throw new Error("不支持的 Data URI 格式");
}
return {
mimeType: match[1],
buffer: Buffer.from(match[2], "base64"),
};
}
async function uploadDataUriAsset(dataUri: string, prefix: "krea-input" | "krea-video") {
const { mimeType, buffer } = extractDataUriParts(dataUri);
const extension = detectExtensionFromContentType(mimeType);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(generateFilename(prefix, extension), arrayBuffer);
return uploaded.url;
}
async function normalizeAssetUrl(url?: string | null) {
const normalized = normalizeText(url);
if (!normalized) return "";
if (/^https?:\/\//i.test(normalized)) return normalized;
if (/^data:(image|video)\//i.test(normalized)) {
return uploadDataUriAsset(
normalized,
/^data:video\//i.test(normalized) ? "krea-video" : "krea-input"
);
}
return normalized;
}
function isVideoUrl(url: string, mediaType?: string | null) {
const normalizedMediaType = String(mediaType || "").toLowerCase();
if (normalizedMediaType.startsWith("video/")) return true;
return /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(url) || /^data:video\//i.test(url);
}
function extractPromptAndAssets(messages: Array<{ role: string; content: any }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("未找到用户输入内容");
}
let prompt = "";
const imageUrls: string[] = [];
const videoUrls: string[] = [];
const pushAssetUrl = (value?: string, mediaType?: string | null) => {
if (!value) return;
const normalized = String(value || "").trim();
if (!normalized) return;
const target = isVideoUrl(normalized, mediaType) ? videoUrls : imageUrls;
if (!target.includes(normalized)) {
target.push(normalized);
}
};
const readContentPart = (part: any) => {
if (!part) return;
if (part.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
return;
}
if (part.type === "image_url" && part.image_url?.url) {
pushAssetUrl(part.image_url.url, "image/jpeg");
return;
}
if (part.type === "file") {
pushAssetUrl(part.url || part.data, part.mediaType || part.mimeType);
return;
}
if (part.type === "image") {
pushAssetUrl(part.image || part.url || part.data, part.mediaType || "image/jpeg");
return;
}
if (part.type === "video") {
pushAssetUrl(part.video || part.url || part.data, part.mediaType || "video/mp4");
}
};
if (typeof lastUserMessage.content === "string") {
prompt = normalizeText(lastUserMessage.content);
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
readContentPart(part);
}
}
return {
prompt: normalizeText(prompt) || "生成内容",
imageUrls,
videoUrls,
};
}
function normalizeAspectRatio(value?: string, fallback = "16:9") {
const normalized = normalizeText(value || fallback);
if (KREA_IMAGE_ASPECT_RATIOS.includes(normalized as any)) {
return normalized;
}
return fallback;
}
function parseAspectRatioValue(ratio: string) {
const [rawW, rawH] = ratio.split(":").map((item) => Number(item));
if (!Number.isFinite(rawW) || !Number.isFinite(rawH) || rawW <= 0 || rawH <= 0) {
return null;
}
return rawW / rawH;
}
function inferNearestKreaImageAspectRatio(width?: number | null, height?: number | null) {
const sourceWidth = Number(width);
const sourceHeight = Number(height);
if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) || sourceWidth <= 0 || sourceHeight <= 0) {
return undefined;
}
const sourceRatio = sourceWidth / sourceHeight;
let bestRatio: string | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const ratio of KREA_OFFICIAL_IMAGE_ASPECT_RATIOS) {
const ratioValue = parseAspectRatioValue(ratio);
if (!ratioValue) continue;
const distance = Math.abs(Math.log(sourceRatio / ratioValue));
if (distance < bestDistance) {
bestDistance = distance;
bestRatio = ratio;
}
}
return bestRatio;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = normalizeText(imageSize).toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return "2K";
}
function resolveImageDimensions(aspectRatio?: string, imageSize?: string) {
const size = normalizeImageSizeLabel(imageSize);
const base = size === "4K" ? 2048 : size === "2K" ? 1536 : 1024;
const ratio = normalizeAspectRatio(aspectRatio, "1:1");
const { width, height } = resolveLongestEdgeDimensions({
aspectRatio: ratio === "auto" ? "1:1" : ratio,
longestEdge: base,
constraints: {
maxEdge: 2048,
maxPixels: 2048 * 2048,
minEdge: 256,
multiple: 16,
},
});
return { width, height, imageSize: size };
}
function resolveVideoDimensions(ratio?: string, resolution?: string) {
const normalizedResolution = normalizeText(resolution).toLowerCase();
const baseHeight = normalizedResolution === "1080p" ? 1080 : 720;
const aspectRatio = normalizeAspectRatio(ratio, "16:9");
if (aspectRatio === "9:16") {
return { width: baseHeight, height: Math.round((baseHeight * 16) / 9) };
}
if (aspectRatio === "1:1") {
return { width: baseHeight, height: baseHeight };
}
if (aspectRatio === "4:3") {
return { width: Math.round((baseHeight * 4) / 3), height: baseHeight };
}
if (aspectRatio === "3:4") {
return { width: baseHeight, height: Math.round((baseHeight * 4) / 3) };
}
if (aspectRatio === "21:9") {
return { width: Math.round((baseHeight * 21) / 9), height: baseHeight };
}
return { width: Math.round((baseHeight * 16) / 9), height: baseHeight };
}
function normalizeSeed(seed?: number) {
if (!Number.isFinite(Number(seed))) return undefined;
return clamp(Math.round(Number(seed)), 0, 4294967295);
}
function normalizeVideoDuration(duration: unknown, endpoint: string) {
const value = Number.isFinite(Number(duration)) ? Math.round(Number(duration)) : 5;
if (endpoint.includes("/bytedance/seedance-2")) {
return clamp(value || 5, 4, 15);
}
if (endpoint.includes("/minimax/hailuo-02") || endpoint.includes("/minimax/hailuo-2.3")) {
return clamp(value || 6, 6, 10);
}
if (endpoint.includes("/google/veo")) {
return clamp(value || 8, 4, 8);
}
if (endpoint.includes("/openai/sora-2")) {
return clamp(value || 4, 4, 20);
}
if (endpoint.includes("/kling/")) {
return value >= 8 ? 10 : 5;
}
if (endpoint.includes("/alibaba/wan-2.5")) {
return clamp(value || 5, 5, 10);
}
return value >= 8 ? 10 : 5;
}
function normalizeVideoResolution(resolution: unknown, endpoint: string) {
const normalized = normalizeText(String(resolution || "")).toLowerCase();
if (endpoint.includes("/bytedance/seedance-2")) {
return normalized === "480p" ? "480p" : "720p";
}
if (endpoint.includes("/minimax/")) {
if (normalized === "1080p") return "1080p";
if (normalized === "512p" || normalized === "480p") return "512p";
return "768p";
}
if (normalized === "1080p" || normalized === "480p") return normalized;
return "720p";
}
function normalizeVideoAspectRatio(ratio: unknown, endpoint: string) {
const normalized = normalizeAspectRatio(String(ratio || ""), "16:9");
if (endpoint.includes("/bytedance/seedance-2")) {
return normalized === "9:16" ? "9:16" : "16:9";
}
if (endpoint.includes("/openai/sora-2")) {
return normalized === "9:16" ? "9:16" : "16:9";
}
if (endpoint.includes("/google/veo") || endpoint.includes("/kling/")) {
return ["16:9", "9:16", "1:1"].includes(normalized) ? normalized : "16:9";
}
return normalized === "auto" ? "16:9" : normalized;
}
function buildStyleImages(imageUrls: string[]) {
return imageUrls.map((url) => ({ url, strength: 1 }));
}
function buildTaggedReferences(imageUrls: string[], tagPrefix = "ref", max = 3) {
return imageUrls.slice(0, max).map((url, index) => ({
url,
tag: `${tagPrefix}_${index + 1}`.replace(/[^a-z0-9_]/gi, "_").toLowerCase(),
}));
}
const KREA_SOURCE_IMAGE_FIELDS = new Set([
"imageUrls",
"image_urls",
"imageUrl",
"image_url",
"referenceImages",
"reference_images",
"init_image_url",
"references",
]);
function hasKreaSourceImageField(fields: Iterable<string>) {
for (const field of fields) {
if (KREA_SOURCE_IMAGE_FIELDS.has(field)) return true;
}
return false;
}
function hasAnyKreaField(fields: Set<string>, aliases: string[]) {
return aliases.some((field) => fields.has(field));
}
function getPreferredKreaField(fields: Set<string>, aliases: string[]) {
for (const field of aliases) {
if (fields.has(field)) {
return field;
}
}
return null;
}
function buildReferencePreservingPrompt(params: {
endpoint: string;
kind: KreaModelKind;
prompt: string;
imageUrls: string[];
requestFields: string[];
}) {
const { endpoint, kind, prompt, imageUrls, requestFields } = params;
if (kind === "video" || imageUrls.length === 0) return prompt;
if (endpoint.includes("/generate/enhance/")) return prompt;
if (!hasKreaSourceImageField(requestFields)) return prompt;
return [
"Use the provided reference image(s) as the source image(s). Preserve the original subject, composition, camera angle, geometry, layout, object positions, and spatial relationships. Only apply the user's requested transformation. Do not invent a new unrelated scene.",
"请严格以用户上传的参考图为唯一源图,保持原图主体、构图、视角、几何结构、空间布局、物体位置和相对关系,只执行用户要求的转换,不要生成无关的新场景。",
`用户要求:${prompt}`,
].join("\n\n");
}
function summarizeKreaUrl(url?: string | null) {
const normalized = normalizeText(url);
if (!normalized) return undefined;
try {
const parsed = new URL(normalized);
return `${parsed.origin}${parsed.pathname}`.slice(0, 160);
} catch {
return normalized.slice(0, 160);
}
}
function summarizeKreaReferenceFields(body: Record<string, any>) {
const summary: Record<string, any> = {};
const summarizeArray = (value: unknown[]) => ({
count: value.length,
first:
typeof value[0] === "string"
? summarizeKreaUrl(value[0])
: value[0] && typeof value[0] === "object"
? summarizeKreaUrl(
(value[0] as Record<string, any>).url ||
(value[0] as Record<string, any>).uri ||
(value[0] as Record<string, any>).image?.[0]
)
: undefined,
});
for (const field of [
"imageUrls",
"image_urls",
"imageUrl",
"image_url",
"styleImages",
"style_images",
"imageStyleRefs",
"image_style_references",
"characterReferenceImages",
"character_reference_images",
"referenceImages",
"reference_images",
"startImage",
"endImage",
"init_image_url",
"references",
"subjects",
"styleReferences",
"style_references",
"elementReferences",
"element_references",
]) {
const value = body[field];
if (Array.isArray(value)) {
summary[field] = summarizeArray(value);
} else if (typeof value === "string") {
summary[field] = summarizeKreaUrl(value);
}
}
return summary;
}
function collectHttpUrls(payload: unknown): string[] {
const urls = new Set<string>();
const visit = (value: unknown) => {
if (typeof value === "string") {
const normalized = value.trim();
if (/^https?:\/\//i.test(normalized)) {
urls.add(normalized);
}
return;
}
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (value && typeof value === "object") {
Object.values(value as Record<string, unknown>).forEach(visit);
}
};
visit(payload);
return Array.from(urls);
}
async function downloadRemoteAsset(url: string) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000);
try {
const response = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`下载 Krea 输出失败: ${response.status}`);
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get("content-type"),
};
} finally {
clearTimeout(timeoutId);
}
}
function stringifyForLog(value: unknown) {
try {
return JSON.stringify(value, null, 2).slice(0, 4000);
} catch {
return String(value).slice(0, 4000);
}
}
function isLikelyKreaHostedUrl(url: string) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === "krea.ai" || hostname.endsWith(".krea.ai");
} catch {
return false;
}
}
async function uploadKreaReferenceAsset(
baseURL: string,
apiKey: string,
sourceUrl: string
) {
const normalized = normalizeText(sourceUrl);
if (!/^https?:\/\//i.test(normalized) || isLikelyKreaHostedUrl(normalized)) {
return normalized;
}
try {
const { buffer, contentType } = await downloadRemoteAsset(normalized);
const mimeType = contentType || "application/octet-stream";
if (!/^(image|video)\//i.test(mimeType)) return normalized;
const form = new FormData();
const blob = new Blob([buffer], { type: mimeType });
form.append("file", blob, generateFilename("krea-reference", detectExtensionFromContentType(mimeType)));
form.append("description", "Reference asset uploaded for Krea API generation");
const response = await fetch(joinEndpoint(baseURL, "/assets"), {
method: "POST",
headers: {
Authorization: `Bearer ${normalizeKreaApiKey(apiKey)}`,
Accept: "application/json",
},
body: form,
});
const rawText = await response.text();
let payload: any = null;
try {
payload = rawText ? JSON.parse(rawText) : null;
} catch {
payload = rawText;
}
if (!response.ok) {
console.warn("[Krea] Reference asset upload failed, falling back to source URL:", {
status: response.status,
sourceUrl: summarizeKreaUrl(normalized),
response: stringifyForLog(payload || rawText),
});
return normalized;
}
const assetUrl = normalizeText(payload?.image_url || payload?.url || payload?.asset_url);
return assetUrl || normalized;
} catch (error) {
console.warn("[Krea] Reference asset upload failed, falling back to source URL:", {
sourceUrl: summarizeKreaUrl(normalized),
error,
});
return normalized;
}
}
async function inferReferenceAspectRatioFromImageUrl(url?: string | null) {
const normalized = normalizeText(url);
if (!normalized || !/^https?:\/\//i.test(normalized)) return undefined;
try {
const { buffer, contentType } = await downloadRemoteAsset(normalized);
if (!String(contentType || "").startsWith("image/")) return undefined;
const metadata = await sharp(buffer, { failOn: "none" }).metadata();
return inferNearestKreaImageAspectRatio(metadata.width, metadata.height);
} catch (error) {
console.warn("[Krea] Failed to infer reference aspect ratio:", {
imageUrl: summarizeKreaUrl(normalized),
error,
});
return undefined;
}
}
async function uploadGeneratedBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> {
const thumbnailBuffer = await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const originalUploaded = await uploadFile(
generateFilename("krea-image-orig", extension === "bin" ? "png" : extension),
originalArrayBuffer
);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const thumbnailUploaded = await uploadFile(
generateFilename("krea-image-thumb", "jpg"),
thumbnailArrayBuffer
);
return {
thumbnail: thumbnailUploaded.url,
original: originalUploaded.url,
};
}
async function uploadKreaImages(payload: unknown) {
const uploadedImages: UploadedImage[] = [];
for (const url of collectHttpUrls(payload)) {
try {
const { buffer, contentType } = await downloadRemoteAsset(url);
if (!String(contentType || "").startsWith("image/")) continue;
uploadedImages.push(await uploadGeneratedBuffer(buffer, detectExtensionFromContentType(contentType)));
} catch (error) {
console.warn("[Krea] Failed to archive image output:", error);
}
}
if (uploadedImages.length === 0) {
throw new Error("Krea 未返回可用图片");
}
return uploadedImages;
}
async function uploadVideoToStorage(remoteVideoUrl: string) {
try {
const { buffer, contentType } = await downloadRemoteAsset(remoteVideoUrl);
const extension = detectExtensionFromContentType(contentType || "video/mp4");
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(
generateFilename("krea-video", extension === "bin" ? "mp4" : extension),
arrayBuffer
);
return uploaded.url;
} catch (error) {
console.warn("[Krea] Failed to archive video output, using temporary URL:", error);
return remoteVideoUrl;
}
}
async function resolveKreaVideoUrl(payload: unknown) {
const outputUrls = collectHttpUrls(payload);
if (outputUrls.length === 0) {
throw new Error("Krea 未返回可用视频地址");
}
const videoUrl =
outputUrls.find((url) => /\.(mp4|mov|webm)(\?|#|$)/i.test(url) || /video/i.test(url)) ||
outputUrls[0]!;
return uploadVideoToStorage(videoUrl);
}
function formatImageMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.join("\n\n");
}
function formatVideoMarkdown(videoUrl: string) {
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
}
async function requestKreaJob(
baseURL: string,
apiKey: string,
endpoint: string,
body: Record<string, any>
) {
const response = await fetch(joinEndpoint(baseURL, endpoint), {
method: "POST",
headers: buildHeaders(apiKey),
body: JSON.stringify(body),
});
const rawText = await response.text();
let payload: any = null;
try {
payload = rawText ? JSON.parse(rawText) : null;
} catch {
payload = rawText;
}
if (!response.ok) {
console.error("[Krea] Job request failed:", {
endpoint,
status: response.status,
requestFields: Object.keys(body),
response: stringifyForLog(payload || rawText),
});
const message =
typeof payload?.error === "string"
? payload.error
: typeof payload?.message === "string"
? payload.message
: rawText || "Krea 请求失败";
throw new Error(`${message} (HTTP ${response.status})`);
}
const jobId = normalizeText(payload?.job_id || payload?.id);
if (!jobId && Array.isArray(payload?.result?.urls) && payload.result.urls.length > 0) {
return payload;
}
if (!jobId) {
throw new Error("Krea 未返回任务 ID");
}
console.log("[Krea] Job accepted:", {
endpoint,
jobId,
status: normalizeText(payload?.status) || null,
});
return { ...payload, job_id: jobId };
}
async function waitForKreaJob(
baseURL: string,
apiKey: string,
jobId: string,
kind: KreaModelKind
) {
const isVideo = kind === "video";
const deadline = Date.now() + (isVideo ? VIDEO_TIMEOUT_MS : IMAGE_TIMEOUT_MS);
const pollInterval = isVideo ? VIDEO_POLL_INTERVAL_MS : IMAGE_POLL_INTERVAL_MS;
while (Date.now() < deadline) {
const response = await fetch(joinEndpoint(baseURL, `/jobs/${jobId}`), {
method: "GET",
headers: buildHeaders(apiKey),
cache: "no-store",
});
const rawText = await response.text();
let payload: any = null;
try {
payload = rawText ? JSON.parse(rawText) : null;
} catch {
payload = rawText;
}
if (!response.ok) {
const message =
typeof payload?.error === "string"
? payload.error
: typeof payload?.message === "string"
? payload.message
: rawText || "Krea 轮询失败";
throw new Error(`${message} (HTTP ${response.status})`);
}
const status = normalizeText(payload?.status).toLowerCase();
if (status === "completed") {
return payload;
}
if (status === "failed" || status === "cancelled") {
console.error("[Krea] Job failed:", {
jobId,
kind,
status,
response: stringifyForLog(payload),
});
throw new Error(String(payload?.error || payload?.message || "Krea 任务失败"));
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new Error("Krea 任务超时,请稍后重试");
}
function getRequiredTopazModel(endpoint: string) {
if (endpoint.includes("/standard-enhance")) return "Standard V2";
if (endpoint.includes("/bloom-enhance")) return "Reimagine";
if (endpoint.includes("/generative-enhance")) return "Redefine";
return undefined;
}
function getRequiredModelValue(endpoint: string) {
if (endpoint.includes("/seedream-3")) return "seedream-3-0-t2i-250415";
if (endpoint.includes("/minimax/hailuo")) return "video-01";
return getRequiredTopazModel(endpoint);
}
function buildKreaBody(params: {
endpoint: string;
kind: KreaModelKind;
requestFields: string[];
requiredFields: string[];
prompt: string;
imageUrls: string[];
videoUrls: string[];
referenceAspectRatio?: string;
imageParams?: KreaImageParams;
videoParams?: KreaVideoParams;
}) {
const {
endpoint,
kind,
requestFields,
requiredFields,
prompt,
imageUrls,
videoUrls,
referenceAspectRatio,
imageParams,
videoParams,
} = params;
const fields = new Set(requestFields);
const required = new Set(requiredFields);
const body: Record<string, any> = {};
const isVideo = kind === "video";
const isImageLike = !isVideo;
const videoMode =
isVideo
? videoParams?.mode ||
(imageUrls.length > 1
? "first-last-frame"
: imageUrls.length > 0
? "first-frame"
: "text-to-video")
: undefined;
const hasImageReference =
kind !== "video" && imageUrls.length > 0 && hasKreaSourceImageField(fields);
const requestedImageAspectRatio = normalizeText(imageParams?.aspectRatio).toLowerCase();
const shouldInferImageGeometryFromReference =
hasImageReference && (!requestedImageAspectRatio || requestedImageAspectRatio === "auto");
const inferredReferenceAspectRatio = normalizeAspectRatio(referenceAspectRatio, "");
const ratio = isVideo
? normalizeVideoAspectRatio(videoParams?.ratio, endpoint)
: inferredReferenceAspectRatio || normalizeAspectRatio(imageParams?.aspectRatio, "1:1");
const videoResolution = normalizeVideoResolution(videoParams?.resolution, endpoint);
const imageDimensions = resolveImageDimensions(imageParams?.aspectRatio, imageParams?.imageSize);
const videoDimensions = resolveVideoDimensions(ratio, videoResolution);
if (fields.has("prompt")) body.prompt = prompt;
if (
fields.has("width") &&
(isVideo || !shouldInferImageGeometryFromReference || required.has("width"))
) {
body.width = isVideo ? videoDimensions.width : imageDimensions.width;
}
if (
fields.has("height") &&
(isVideo || !shouldInferImageGeometryFromReference || required.has("height"))
) {
body.height = isVideo ? videoDimensions.height : imageDimensions.height;
}
const aspectRatioField = getPreferredKreaField(
fields,
isImageLike ? ["aspect_ratio", "aspectRatio"] : ["aspectRatio", "aspect_ratio"]
);
if (aspectRatioField && shouldInferImageGeometryFromReference && inferredReferenceAspectRatio) {
body[aspectRatioField] = inferredReferenceAspectRatio;
} else if (
aspectRatioField &&
(isVideo ||
!shouldInferImageGeometryFromReference ||
required.has("aspectRatio") ||
required.has("aspect_ratio"))
) {
body[aspectRatioField] = ratio === "auto" ? "1:1" : ratio;
}
if (fields.has("ratio")) body.ratio = ratio === "auto" ? "16:9" : ratio;
if (fields.has("resolution")) {
body.resolution = isVideo ? videoResolution : normalizeImageSizeLabel(imageParams?.imageSize);
}
const seed = normalizeSeed(isVideo ? videoParams?.seed : imageParams?.seed);
if (fields.has("seed") && seed !== undefined) body.seed = seed;
if (fields.has("negative_prompt") && imageParams?.negativePrompt) {
body.negative_prompt = imageParams.negativePrompt;
}
const skipPromptExpansionField = getPreferredKreaField(fields, [
"skip_prompt_expansion",
"skipPromptExpansion",
]);
if (skipPromptExpansionField && typeof imageParams?.promptExtend === "boolean") {
body[skipPromptExpansionField] = !imageParams.promptExtend;
}
if (fields.has("enablePromptExpansion")) {
body.enablePromptExpansion = true;
}
if (fields.has("expandPrompt")) {
body.expandPrompt = true;
}
if (fields.has("duration")) {
body.duration = normalizeVideoDuration(videoParams?.duration, endpoint);
}
const generateAudioField = getPreferredKreaField(fields, [
"generate_audio",
"generateAudio",
]);
if (generateAudioField && typeof videoParams?.generateAudio === "boolean") {
body[generateAudioField] = videoParams.generateAudio;
}
if (fields.has("loop")) {
body.loop = false;
}
if (fields.has("mode")) {
body.mode = endpoint.includes("/kling/kling-2") ? "master" : "std";
}
if (fields.has("model")) {
const modelValue = getRequiredModelValue(endpoint);
if (modelValue) body.model = modelValue;
}
if (fields.has("output_format")) body.output_format = "jpg";
if (fields.has("backgroundImage")) body.backgroundImage = "auto";
const imageUrlsField = getPreferredKreaField(
fields,
isImageLike ? ["image_urls", "imageUrls"] : ["imageUrls", "image_urls"]
);
if (imageUrlsField && imageUrls.length > 0) {
body[imageUrlsField] = imageUrls.slice(0, 8);
}
const imageUrlField = getPreferredKreaField(
fields,
isImageLike ? ["image_url", "imageUrl"] : ["imageUrl", "image_url"]
);
if (imageUrlField && imageUrls[0]) {
body[imageUrlField] = imageUrls[0];
}
const styleImagesField = getPreferredKreaField(
fields,
isImageLike ? ["style_images", "styleImages"] : ["styleImages", "style_images"]
);
if (
styleImagesField &&
imageUrls.length > 0 &&
!hasAnyKreaField(new Set(Object.keys(body)), [
"image_urls",
"imageUrls",
"image_url",
"imageUrl",
])
) {
body[styleImagesField] = buildStyleImages(imageUrls.slice(0, 4));
}
const imageStyleReferencesField = getPreferredKreaField(fields, [
"image_style_references",
"imageStyleRefs",
]);
if (imageStyleReferencesField && imageUrls.length > 1) {
body[imageStyleReferencesField] = buildStyleImages(imageUrls.slice(1, 5));
}
const characterReferenceImagesField = getPreferredKreaField(fields, [
"character_reference_images",
"characterReferenceImages",
]);
if (characterReferenceImagesField && imageUrls.length > 0) {
body[characterReferenceImagesField] = imageUrls.slice(0, 4);
}
const referenceImagesField = getPreferredKreaField(fields, [
"reference_images",
"referenceImages",
]);
if (
referenceImagesField &&
imageUrls.length > 0 &&
(!isVideo || videoMode === "reference-images")
) {
body[referenceImagesField] = endpoint.includes("/image/runway/gen-4-image")
? buildTaggedReferences(imageUrls, "ref", 3)
: imageUrls.slice(0, endpoint.includes("/seedance-2") ? 9 : 4);
}
if (
fields.has("startImage") &&
imageUrls[0] &&
(!isVideo || videoMode === "first-frame" || videoMode === "first-last-frame")
) {
body.startImage = imageUrls[0];
}
if (fields.has("endImage") && imageUrls[1] && (!isVideo || videoMode === "first-last-frame")) {
body.endImage = imageUrls[1];
}
if (fields.has("init_image_url") && imageUrls[0]) body.init_image_url = imageUrls[0];
if (fields.has("references") && imageUrls[0]) {
body.references = [{ type: "image", uri: imageUrls[0] }];
}
if (fields.has("subjects") && imageUrls[0] && videoParams?.mode === "reference-images") {
body.subjects = [{ type: "character", image: [imageUrls[0]] }];
}
const styleReferencesField = getPreferredKreaField(fields, [
"style_references",
"styleReferences",
]);
if (styleReferencesField && imageUrls.length > 0) {
body[styleReferencesField] = buildTaggedReferences(imageUrls, "style", 3);
}
const elementReferencesField = getPreferredKreaField(fields, [
"element_references",
"elementReferences",
]);
if (elementReferencesField && imageUrls.length > 0) {
body[elementReferencesField] = [
{
tag: "element_1",
images: imageUrls.slice(0, 3),
},
];
}
const videoReferenceField = getPreferredKreaField(fields, [
"video_reference",
"videoReference",
]);
if (videoReferenceField && videoUrls[0]) {
body[videoReferenceField] = { url: videoUrls[0], tag: "video_1" };
}
if (fields.has("startVideo") && videoUrls[0]) body.startVideo = videoUrls[0];
if (fields.has("init_video") && videoUrls[0]) body.init_video = videoUrls[0];
const referenceVideosField = getPreferredKreaField(fields, [
"reference_videos",
"referenceVideos",
]);
if (referenceVideosField && videoUrls.length > 0) {
body[referenceVideosField] = videoUrls.slice(0, 3);
}
if (
(required.has("referenceImages") || required.has("reference_images")) &&
!body.referenceImages &&
!body.reference_images
) {
throw new Error("该 Krea 模型需要至少 1 张参考图");
}
if ((required.has("imageUrl") || required.has("image_url")) && !body.imageUrl && !body.image_url) {
throw new Error("该 Krea 模型需要上传图片");
}
if (required.has("init_video") && !body.init_video) {
throw new Error("该 Krea 模型需要上传视频");
}
return { body, imageSize: imageDimensions.imageSize };
}
export function createKrea({ apiKey, baseURL }: KreaConfig) {
return {
provider: "krea",
chat: (modelId: string) => ({
provider: "krea",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: KreaImageParams;
videoParams?: KreaVideoParams;
overrideModelId?: string;
}) => {
try {
const activeModelId = normalizeText(options.overrideModelId || modelId);
const endpoint = resolveKreaEndpoint(activeModelId);
const preset = getKreaModelPresetById(endpoint);
const kind =
preset?.kind ||
inferKreaModelKind(endpoint) ||
(options.videoParams ? "video" : "image");
const { prompt, imageUrls, videoUrls } = extractPromptAndAssets(options.messages);
const normalizedImageUrls = (
await Promise.all(imageUrls.map((url) => normalizeAssetUrl(url)))
).filter(Boolean);
const normalizedVideoUrls = (
await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url)))
).filter(Boolean);
const kreaImageUrls = (
await Promise.all(
normalizedImageUrls.map((url) => uploadKreaReferenceAsset(baseURL, apiKey, url))
)
).filter(Boolean);
const requestFields =
preset?.requestFields ||
(kind === "video"
? ["prompt", "aspectRatio", "duration", "startImage", "endImage", "referenceImages"]
: ["prompt", "width", "height", "image_urls", "image_url"]);
const requiredFields = preset?.requiredFields || ["prompt"];
const requestedImageAspectRatio = normalizeText(options.imageParams?.aspectRatio).toLowerCase();
const referenceAspectRatio =
kind !== "video" &&
normalizedImageUrls[0] &&
hasKreaSourceImageField(requestFields) &&
(!requestedImageAspectRatio || requestedImageAspectRatio === "auto")
? await inferReferenceAspectRatioFromImageUrl(normalizedImageUrls[0])
: undefined;
const promptForKrea = buildReferencePreservingPrompt({
endpoint,
kind,
prompt,
imageUrls: kreaImageUrls,
requestFields,
});
const { body, imageSize } = buildKreaBody({
endpoint,
kind,
requestFields,
requiredFields,
prompt: promptForKrea,
imageUrls: kreaImageUrls,
videoUrls: normalizedVideoUrls,
referenceAspectRatio,
imageParams: options.imageParams,
videoParams: options.videoParams,
});
console.log("[Krea] Submitting job:", {
endpoint,
kind,
requestFields: Object.keys(body),
imageCount: normalizedImageUrls.length,
videoCount: normalizedVideoUrls.length,
referenceAspectRatio,
referenceFields: summarizeKreaReferenceFields(body),
referencePreservingPrompt: promptForKrea !== prompt,
});
const job = await requestKreaJob(baseURL, apiKey, endpoint, body);
const output = job?.result?.urls?.length
? job
: await waitForKreaJob(baseURL, apiKey, job.job_id, kind);
const text =
kind === "video"
? formatVideoMarkdown(await resolveKreaVideoUrl(output?.result ?? output))
: formatImageMarkdown(await uploadKreaImages(output?.result ?? output), imageSize);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[Krea] Generation failed:", error);
const kind =
inferKreaModelKind(options.overrideModelId || modelId) ||
(options.videoParams ? "video" : "image");
return {
text:
error instanceof Error
? `${kind === "video" ? "视频" : "图片"}生成失败: ${error.message}`
: "Krea 生成失败,请稍后重试",
finishReason: "error" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
}
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: KreaImageParams;
videoParams?: KreaVideoParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
modelId,
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
};
}