Your Name 947af6d26f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add runway image and video runtime support
2026-03-20 22:33:09 +08:00

582 lines
18 KiB
TypeScript

import "server-only";
import sharp from "sharp";
import { uploadFile } from "@/lib/storage/service";
import { inferRunwayModelKind } from "@/lib/ai/model-classification";
interface RunwayConfig {
apiKey: string;
baseURL: string;
}
interface RunwayImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
seed?: number;
}
interface RunwayVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
ratio?: string;
duration?: number;
seed?: number;
}
interface UploadedImage {
thumbnail: string;
original: string;
}
const RUNWAY_VERSION = "2024-11-06";
const DEFAULT_BASE_URL = "https://api.dev.runwayml.com";
const TASK_POLL_INTERVAL_MS = 5000;
const TASK_TIMEOUT_MS = 15 * 60 * 1000;
const IMAGE_RESOLUTION_MAP: Record<string, Record<string, string>> = {
"1K": {
"21:9": "1584:672",
"16:9": "1360:768",
"9:16": "768:1360",
"4:3": "1104:832",
"3:4": "832:1104",
"1:1": "960:960",
},
"2K": {
"21:9": "1584:672",
"16:9": "1360:768",
"9:16": "768:1360",
"4:3": "1104:832",
"3:4": "832:1104",
"1:1": "960:960",
},
"4K": {
"21:9": "1584:672",
"16:9": "1360:768",
"9:16": "768:1360",
"4:3": "1104:832",
"3:4": "832:1104",
"1:1": "960:960",
},
};
function normalizeBaseURL(baseURL: string) {
const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
return normalized || DEFAULT_BASE_URL;
}
function joinEndpoint(baseURL: string, path: string) {
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${normalizeBaseURL(baseURL)}${cleanPath}`;
}
function buildHeaders(apiKey: string) {
return {
Authorization: `Bearer ${String(apiKey || "").trim()}`,
"Content-Type": "application/json",
"X-Runway-Version": RUNWAY_VERSION,
};
}
function normalizeText(value: string) {
return String(value || "").replace(/\u00a0/g, " ").trim().normalize("NFKC");
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return "2K";
}
function normalizeImageResolution(aspectRatio?: string, imageSize?: string) {
const normalizedRatio = String(aspectRatio || "").trim() || "16:9";
const normalizedSize = normalizeImageSizeLabel(imageSize);
return (
IMAGE_RESOLUTION_MAP[normalizedSize]?.[normalizedRatio] ||
IMAGE_RESOLUTION_MAP[normalizedSize]?.["16:9"] ||
"1360:768"
);
}
function normalizeVideoRatio(ratio?: string) {
const normalized = String(ratio || "").trim();
if (["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(normalized)) {
return normalized;
}
return "16:9";
}
function normalizeVideoDuration(duration?: number) {
if (!Number.isFinite(duration)) return 5;
const normalized = Math.round(Number(duration));
return normalized >= 8 ? 10 : 5;
}
function normalizeSeed(seed?: number) {
if (!Number.isFinite(seed)) return undefined;
return Math.max(0, Math.min(4294967295, Math.round(Number(seed))));
}
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 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"),
};
}
function generateFilename(prefix: string, extension: string) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
async function uploadGeneratedBuffer(
buffer: Buffer,
extension: string,
mimeType: string
): Promise<UploadedImage> {
const thumbnailBuffer = await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
const originalFilename = generateFilename("runway-image", extension);
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
const thumbnailFilename = generateFilename("runway-thumb", "jpg");
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const thumbnailUploaded = await uploadFile(thumbnailFilename, thumbnailArrayBuffer);
console.log("[Runway] Uploaded image to storage:", {
original: originalUploaded.url,
thumbnail: thumbnailUploaded.url,
mimeType,
});
return {
thumbnail: thumbnailUploaded.url,
original: originalUploaded.url,
};
}
async function uploadDataUriAsset(dataUri: string, prefix: "runway-input" | "runway-video") {
const { mimeType, buffer } = extractDataUriParts(dataUri);
const extension = detectExtensionFromContentType(mimeType);
const filename = generateFilename(prefix, extension);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(filename, arrayBuffer);
return uploaded.url;
}
async function normalizeAssetUrl(url?: string | null) {
const normalized = String(url || "").trim();
if (!normalized) return "";
if (/^https:\/\//i.test(normalized) || /^runway:\/\//i.test(normalized)) {
return normalized;
}
if (/^data:(image|video)\//i.test(normalized)) {
return uploadDataUriAsset(normalized, /^data:video\//i.test(normalized) ? "runway-video" : "runway-input");
}
return normalized;
}
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(`下载 Runway 输出失败: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
return {
buffer,
contentType: response.headers.get("content-type"),
};
} finally {
clearTimeout(timeoutId);
}
}
async function uploadVideoToStorage(remoteVideoUrl: string) {
try {
const { buffer, contentType } = await downloadRemoteAsset(remoteVideoUrl);
const extension = detectExtensionFromContentType(contentType || "video/mp4");
const filename = generateFilename("runway-video", extension === "bin" ? "mp4" : extension);
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const uploaded = await uploadFile(filename, arrayBuffer);
return uploaded.url;
} catch (error) {
console.warn("[Runway] Failed to archive video output, using temporary URL:", error);
return remoteVideoUrl;
}
}
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})`;
}
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 pushImageUrl = (value?: string) => {
if (!value) return;
const normalized = String(value || "").trim();
if (!normalized) return;
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
if (typeof lastUserMessage.content === "string") {
prompt = normalizeText(lastUserMessage.content);
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
if (part?.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
} else if (part?.type === "image_url" && part.image_url?.url) {
pushImageUrl(part.image_url.url);
} else if (part?.type === "file") {
pushImageUrl(part.url || part.data);
} else if (part?.type === "image") {
pushImageUrl(part.image || part.url);
}
}
}
return {
prompt: normalizeText(prompt) || "生成内容",
imageUrls,
};
}
async function createTask(baseURL: string, apiKey: string, path: string, body: Record<string, any>) {
const response = await fetch(joinEndpoint(baseURL, path), {
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) {
const message =
typeof payload?.error === "string"
? payload.error
: typeof payload?.message === "string"
? payload.message
: rawText || "Runway 请求失败";
throw new Error(`${message} (HTTP ${response.status})`);
}
const taskId = String(payload?.id || "").trim();
if (!taskId) {
throw new Error("Runway 未返回任务 ID");
}
return taskId;
}
async function waitForTaskOutput(baseURL: string, apiKey: string, taskId: string) {
const deadline = Date.now() + TASK_TIMEOUT_MS;
while (Date.now() < deadline) {
const response = await fetch(joinEndpoint(baseURL, `/v1/tasks/${taskId}`), {
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 || "Runway 轮询失败";
throw new Error(`${message} (HTTP ${response.status})`);
}
const status = String(payload?.status || "").trim().toUpperCase();
if (status === "SUCCEEDED") {
return payload;
}
if (status === "FAILED" || status === "CANCELLED") {
throw new Error(
String(payload?.failure || payload?.error || payload?.message || "Runway 任务失败")
);
}
await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MS));
}
throw new Error("Runway 任务超时,请稍后重试");
}
async function uploadRunwayImages(taskOutput: unknown) {
const outputUrls = collectHttpUrls(taskOutput);
const uploadedImages: UploadedImage[] = [];
for (const url of outputUrls) {
try {
const { buffer, contentType } = await downloadRemoteAsset(url);
if (!String(contentType || "").startsWith("image/")) {
continue;
}
const extension = detectExtensionFromContentType(contentType);
uploadedImages.push(
await uploadGeneratedBuffer(
buffer,
extension === "bin" ? "png" : extension,
contentType || "image/png"
)
);
} catch (error) {
console.warn("[Runway] Failed to archive image output:", error);
}
}
if (uploadedImages.length === 0) {
throw new Error("Runway 未返回可用图片");
}
return uploadedImages;
}
async function resolveRunwayVideoUrl(taskOutput: unknown) {
const outputUrls = collectHttpUrls(taskOutput);
if (outputUrls.length === 0) {
throw new Error("Runway 未返回可用视频地址");
}
for (const url of outputUrls) {
if (/(\.mp4|\.mov|\.webm)(\?|$)/i.test(url) || /video/i.test(url)) {
return uploadVideoToStorage(url);
}
}
return uploadVideoToStorage(outputUrls[0]!);
}
function getRunwayVideoEndpoint(modelId: string, hasPromptImage: boolean) {
const normalized = normalizeText(modelId);
if (hasPromptImage) return "/v1/image_to_video";
if (normalized === "gen4.5") return "/v1/image_to_video";
return "/v1/text_to_video";
}
function buildReferenceImages(imageUrls: string[]) {
return imageUrls.slice(0, 3).map((uri, index) => ({
uri,
tag: `ref_${index + 1}`.replace(/[^a-z0-9_]/g, "_"),
}));
}
export function createRunway({ apiKey, baseURL }: RunwayConfig) {
return {
provider: "runway",
chat: (modelId: string) => ({
provider: "runway",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: RunwayImageParams;
videoParams?: RunwayVideoParams;
overrideModelId?: string;
}) => {
try {
const activeModelId = normalizeText(options.overrideModelId || modelId);
const { prompt, imageUrls } = extractPromptAndAssets(options.messages);
const normalizedImageUrls = (
await Promise.all(imageUrls.map((url) => normalizeAssetUrl(url)))
).filter(Boolean);
const modelKind =
inferRunwayModelKind(activeModelId) ||
(options.videoParams ? "video" : options.imageParams ? "image" : null) ||
"video";
if (modelKind === "image") {
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
const taskId = await createTask(baseURL, apiKey, "/v1/text_to_image", {
model: activeModelId,
promptText: prompt,
ratio: normalizeImageResolution(options.imageParams?.aspectRatio, imageSize),
...(normalizedImageUrls.length > 0
? { referenceImages: buildReferenceImages(normalizedImageUrls) }
: {}),
...(normalizeSeed(options.imageParams?.seed) !== undefined
? { seed: normalizeSeed(options.imageParams?.seed) }
: {}),
});
const task = await waitForTaskOutput(baseURL, apiKey, taskId);
const uploadedImages = await uploadRunwayImages(task?.output ?? task);
const text = formatImageMarkdown(uploadedImages, imageSize);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
}
const videoMode = options.videoParams?.mode || (normalizedImageUrls[0] ? "first-frame" : "text-to-video");
if (
(videoMode === "first-frame" || videoMode === "first-last-frame" || videoMode === "reference-images") &&
!normalizedImageUrls[0]
) {
throw new Error("Runway 图生视频至少需要 1 张参考图");
}
const endpoint = getRunwayVideoEndpoint(activeModelId, Boolean(normalizedImageUrls[0]));
const taskId = await createTask(baseURL, apiKey, endpoint, {
model: activeModelId,
promptText: prompt,
ratio: normalizeVideoRatio(options.videoParams?.ratio),
duration: normalizeVideoDuration(options.videoParams?.duration),
...(normalizedImageUrls[0] ? { promptImage: normalizedImageUrls[0] } : {}),
...(normalizeSeed(options.videoParams?.seed) !== undefined
? { seed: normalizeSeed(options.videoParams?.seed) }
: {}),
});
const task = await waitForTaskOutput(baseURL, apiKey, taskId);
const videoUrl = await resolveRunwayVideoUrl(task?.output ?? task);
const text = formatVideoMarkdown(videoUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[Runway] Generation failed:", error);
return {
text:
error instanceof Error
? `${
(inferRunwayModelKind(options.overrideModelId || modelId) ||
(options.videoParams ? "video" : options.imageParams ? "image" : "video")) === "image"
? "图片"
: "视频"
}生成失败: ${error.message}`
: "Runway 生成失败,请稍后重试",
finishReason: "error" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
}
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: RunwayImageParams;
videoParams?: RunwayVideoParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
modelId,
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
};
}