833 lines
27 KiB
TypeScript
833 lines
27 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;
|
||
|
||
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 (
|
||
[
|
||
"21:9",
|
||
"16:9",
|
||
"9:16",
|
||
"4:3",
|
||
"3:4",
|
||
"1:1",
|
||
"9:21",
|
||
"auto",
|
||
].includes(normalized)
|
||
) {
|
||
return normalized;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
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(),
|
||
}));
|
||
}
|
||
|
||
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 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[];
|
||
imageParams?: KreaImageParams;
|
||
videoParams?: KreaVideoParams;
|
||
}) {
|
||
const {
|
||
endpoint,
|
||
kind,
|
||
requestFields,
|
||
requiredFields,
|
||
prompt,
|
||
imageUrls,
|
||
videoUrls,
|
||
imageParams,
|
||
videoParams,
|
||
} = params;
|
||
const fields = new Set(requestFields);
|
||
const required = new Set(requiredFields);
|
||
const body: Record<string, any> = {};
|
||
const isVideo = kind === "video";
|
||
const ratio = isVideo
|
||
? normalizeVideoAspectRatio(videoParams?.ratio, endpoint)
|
||
: 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")) body.width = isVideo ? videoDimensions.width : imageDimensions.width;
|
||
if (fields.has("height")) body.height = isVideo ? videoDimensions.height : imageDimensions.height;
|
||
if (fields.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 { body, imageSize } = buildKreaBody({
|
||
endpoint,
|
||
kind,
|
||
requestFields,
|
||
requiredFields,
|
||
prompt,
|
||
imageUrls: normalizedImageUrls,
|
||
videoUrls: normalizedVideoUrls,
|
||
imageParams: options.imageParams,
|
||
videoParams: options.videoParams,
|
||
});
|
||
|
||
console.log("[Krea] Submitting job:", {
|
||
endpoint,
|
||
kind,
|
||
requestFields: Object.keys(body),
|
||
imageCount: normalizedImageUrls.length,
|
||
videoCount: normalizedVideoUrls.length,
|
||
});
|
||
|
||
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,
|
||
}),
|
||
};
|
||
}
|