1017 lines
34 KiB
TypeScript
1017 lines
34 KiB
TypeScript
import "server-only";
|
||
|
||
import sharp from "sharp";
|
||
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 roundToMultiple(value: number, multiple = 16) {
|
||
return Math.max(multiple, Math.round(value / multiple) * multiple);
|
||
}
|
||
|
||
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");
|
||
if (ratio === "auto" || !ratio.includes(":")) {
|
||
return { width: base, height: base, imageSize: size };
|
||
}
|
||
|
||
const [rawW, rawH] = ratio.split(":").map((item) => Number(item));
|
||
const ratioW = Number.isFinite(rawW) && rawW > 0 ? rawW : 1;
|
||
const ratioH = Number.isFinite(rawH) && rawH > 0 ? rawH : 1;
|
||
const landscape = ratioW >= ratioH;
|
||
const width = landscape ? base : Math.round((base * ratioW) / ratioH);
|
||
const height = landscape ? Math.round((base * ratioH) / ratioW) : base;
|
||
return {
|
||
width: clamp(roundToMultiple(width), 256, 2048),
|
||
height: clamp(roundToMultiple(height), 256, 2048),
|
||
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 normalizeBatchSize(value?: number) {
|
||
if (!Number.isFinite(Number(value))) return 1;
|
||
return clamp(Math.round(Number(value)), 1, 4);
|
||
}
|
||
|
||
function normalizeVideoDuration(duration: unknown, endpoint: string) {
|
||
const value = Number.isFinite(Number(duration)) ? Math.round(Number(duration)) : 5;
|
||
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("/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("/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",
|
||
"imageUrl",
|
||
"image_url",
|
||
"referenceImages",
|
||
"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 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",
|
||
"imageUrl",
|
||
"image_url",
|
||
"styleImages",
|
||
"imageStyleRefs",
|
||
"characterReferenceImages",
|
||
"referenceImages",
|
||
"startImage",
|
||
"endImage",
|
||
"init_image_url",
|
||
"references",
|
||
"subjects",
|
||
"styleReferences",
|
||
"elementReferences",
|
||
]) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
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) => ``)
|
||
.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: payload,
|
||
});
|
||
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");
|
||
}
|
||
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") {
|
||
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 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("batchSize")) body.batchSize = normalizeBatchSize(imageParams?.imageCount);
|
||
|
||
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;
|
||
}
|
||
if (fields.has("aspectRatio") && shouldInferImageGeometryFromReference && inferredReferenceAspectRatio) {
|
||
body.aspectRatio = inferredReferenceAspectRatio;
|
||
} else if (
|
||
fields.has("aspectRatio") &&
|
||
(isVideo || !shouldInferImageGeometryFromReference || required.has("aspectRatio"))
|
||
) {
|
||
body.aspectRatio = 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;
|
||
}
|
||
if (fields.has("skipPromptExpansion") && typeof imageParams?.promptExtend === "boolean") {
|
||
body.skipPromptExpansion = !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);
|
||
}
|
||
if (fields.has("generateAudio") && typeof videoParams?.generateAudio === "boolean") {
|
||
body.generateAudio = 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";
|
||
|
||
if (fields.has("imageUrls") && imageUrls.length > 0) {
|
||
body.imageUrls = imageUrls.slice(0, 8);
|
||
}
|
||
if (fields.has("imageUrl") && imageUrls[0]) {
|
||
body.imageUrl = imageUrls[0];
|
||
}
|
||
if (fields.has("image_url") && imageUrls[0]) {
|
||
body.image_url = imageUrls[0];
|
||
}
|
||
if (fields.has("styleImages") && imageUrls.length > 0 && !body.imageUrls && !body.imageUrl) {
|
||
body.styleImages = buildStyleImages(imageUrls.slice(0, 4));
|
||
}
|
||
if (fields.has("imageStyleRefs") && imageUrls.length > 1) {
|
||
body.imageStyleRefs = buildStyleImages(imageUrls.slice(1, 5));
|
||
}
|
||
if (fields.has("characterReferenceImages") && imageUrls.length > 0) {
|
||
body.characterReferenceImages = imageUrls.slice(0, 4);
|
||
}
|
||
|
||
if (fields.has("referenceImages") && imageUrls.length > 0) {
|
||
body.referenceImages = 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]) body.startImage = imageUrls[0];
|
||
if (fields.has("endImage") && imageUrls[1]) 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]] }];
|
||
}
|
||
if (fields.has("styleReferences") && imageUrls.length > 0) {
|
||
body.styleReferences = buildTaggedReferences(imageUrls, "style", 3);
|
||
}
|
||
if (fields.has("elementReferences") && imageUrls.length > 0) {
|
||
body.elementReferences = [
|
||
{
|
||
tag: "element_1",
|
||
images: imageUrls.slice(0, 3),
|
||
},
|
||
];
|
||
}
|
||
if (fields.has("videoReference") && videoUrls[0]) {
|
||
body.videoReference = { 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];
|
||
if (fields.has("referenceVideos") && videoUrls.length > 0) {
|
||
body.referenceVideos = videoUrls.slice(0, 3);
|
||
}
|
||
|
||
if (required.has("referenceImages") && !body.referenceImages) {
|
||
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 requestFields =
|
||
preset?.requestFields ||
|
||
(kind === "video"
|
||
? ["prompt", "aspectRatio", "duration", "startImage", "endImage", "referenceImages"]
|
||
: ["prompt", "width", "height", "imageUrls", "imageUrl", "batchSize"]);
|
||
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: normalizedImageUrls,
|
||
requestFields,
|
||
});
|
||
const { body, imageSize } = buildKreaBody({
|
||
endpoint,
|
||
kind,
|
||
requestFields,
|
||
requiredFields,
|
||
prompt: promptForKrea,
|
||
imageUrls: normalizedImageUrls,
|
||
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,
|
||
}),
|
||
};
|
||
}
|