398 lines
13 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 { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
import {
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import {
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
reportWorldLabsProviderRuntimeFailure,
} from "@/lib/worldlabs/server";
import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 120;
const logger = createLogger("world-labs-upload");
const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
const ALLOWED_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
]);
const MAX_IMAGE_UPLOAD_SIZE = 20 * 1024 * 1024;
const MAX_VIDEO_UPLOAD_SIZE = 100 * 1024 * 1024;
const MIME_BY_EXTENSION: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
m4v: "video/x-m4v",
};
function getFilenameFromUrl(sourceUrl: string, fallback = "canvas-source") {
try {
const parsed = new URL(sourceUrl);
const filename = parsed.pathname.split("/").filter(Boolean).pop();
return filename ? decodeURIComponent(filename) : fallback;
} catch {
return fallback;
}
}
function normalizeMimeType(value?: string | null) {
return String(value || "").split(";")[0]?.trim().toLowerCase() || "";
}
function inferMimeType(filename: string, fallback?: string | null) {
const normalizedFallback = normalizeMimeType(fallback);
if (normalizedFallback) return normalizedFallback;
const extension = filename.split(".").pop()?.trim().toLowerCase() || "";
return MIME_BY_EXTENSION[extension] || "application/octet-stream";
}
function guessExtension(file: File) {
const fileName = String(file.name || "");
const lastDotIndex = fileName.lastIndexOf(".");
const fromName =
lastDotIndex >= 0 && lastDotIndex < fileName.length - 1
? fileName.slice(lastDotIndex + 1).trim().toLowerCase()
: "";
if (fromName) return fromName;
const byType: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/webm": "webm",
"video/x-m4v": "m4v",
};
return byType[file.type] || "bin";
}
async function fetchRemoteFileFromFormData(formData: FormData) {
const sourceUrl = String(formData.get("sourceUrl") || "").trim();
if (!sourceUrl) return null;
let parsed: URL;
try {
parsed = new URL(sourceUrl);
} catch {
throw new Error("sourceUrl 格式无效");
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("sourceUrl 仅支持 HTTP/HTTPS 图片或视频");
}
const response = await fetch(sourceUrl, {
cache: "no-store",
headers: {
"User-Agent": "AI-Chatbot-WorldLabs-Upload/1.0",
},
});
if (!response.ok) {
throw new Error(`读取画布图片失败(HTTP ${response.status})`);
}
const buffer = await response.arrayBuffer();
const fallbackFilename = `canvas-source-${Date.now()}`;
const filename = getFilenameFromUrl(sourceUrl, fallbackFilename);
const contentType = inferMimeType(filename, response.headers.get("content-type"));
return new File([buffer], filename, { type: contentType });
}
function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
function readOptionalString(value: unknown) {
const text = String(value || "").trim();
return text || null;
}
function extractUploadInfoFromCurlExample(curlExample: unknown) {
const curl = String(curlExample || "");
const uploadUrl =
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
.map((match) => match[0])
.find(Boolean) || null;
const requiredHeaders: Record<string, string> = {};
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerPattern.exec(curl))) {
const header = String(
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
).trim();
const colonIndex = header.indexOf(":");
if (colonIndex <= 0) continue;
const name = header.slice(0, colonIndex).trim();
const value = header.slice(colonIndex + 1).trim();
if (name && value) {
requiredHeaders[name] = value;
}
}
return { uploadUrl, requiredHeaders };
}
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
const uploadInfo = data.upload_info || {};
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
const explicitHeaders =
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
? uploadInfo.required_headers
: {};
return {
uploadUrl:
readOptionalString(uploadInfo.upload_url) ||
readOptionalString(uploadInfoRecord.uploadUrl) ||
readOptionalString(uploadInfoRecord.url) ||
curlDetails.uploadUrl,
uploadMethod:
readOptionalString(uploadInfo.upload_method) ||
readOptionalString(uploadInfoRecord.method) ||
"PUT",
requiredHeaders: {
...curlDetails.requiredHeaders,
...explicitHeaders,
},
};
}
function isSignedUploadFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return (
message.includes("SignatureDoesNotMatch") ||
message.includes("Access denied") ||
message.includes("Google secret key") ||
message.includes("签名")
);
}
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
const buffer = await file.arrayBuffer();
const safeExtension = extension && extension !== "bin" ? extension : "bin";
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
const uploaded = await uploadFile(filename, buffer);
return uploaded.url;
}
function isCredentialOrBillingProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
[401, 402, 403].includes(error.httpStatus)
);
}
export async function POST(request: Request) {
const startedAt = Date.now();
let providerForLog: string | null = null;
let kindForLog: string | null = null;
let sizeForLog: number | null = null;
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const formFile = formData.get("file");
const file =
formFile instanceof File ? formFile : await fetchRemoteFileFromFormData(formData);
if (!(file instanceof File)) {
return NextResponse.json({ success: false, error: "请上传文件" }, { status: 400 });
}
const requestedKind = String(formData.get("kind") || "").trim();
const isImage = requestedKind
? requestedKind === "image"
: ALLOWED_IMAGE_TYPES.has(file.type);
const isVideo = requestedKind
? requestedKind === "video"
: ALLOWED_VIDEO_TYPES.has(file.type);
kindForLog = isImage ? "image" : isVideo ? "video" : requestedKind || null;
sizeForLog = file.size;
if (!isImage && !isVideo) {
return NextResponse.json(
{ success: false, error: "仅支持 PNG/JPEG/WEBP 图片或 MP4/MOV/WEBM 视频" },
{ status: 400 }
);
}
if (file.size <= 0) {
return NextResponse.json({ success: false, error: "文件不能为空" }, { status: 400 });
}
const maxSize = isImage ? MAX_IMAGE_UPLOAD_SIZE : MAX_VIDEO_UPLOAD_SIZE;
if (file.size > maxSize) {
return NextResponse.json(
{
success: false,
error: isImage ? "图片大小不能超过 20MB" : "视频大小不能超过 100MB",
},
{ status: 400 }
);
}
const requestedProviderId = String(formData.get("providerId") || "").trim();
const candidates = requestedProviderId
? [await getWorldLabsProviderConfig(requestedProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
const extension = guessExtension(file);
let lastError: unknown = null;
for (const config of candidates) {
providerForLog = config.providerId || null;
try {
const prepareUpload = await worldLabsRequest<WorldLabsPrepareUploadResponse>(
config,
{
method: "POST",
path: "/marble/v1/media-assets:prepare_upload",
json: {
file_name: file.name || `upload.${extension}`,
extension,
kind: isImage ? "image" : "video",
metadata: {
mime_type: file.type || null,
uploaded_from: "canvas_world_mode",
},
},
}
);
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
const { uploadUrl, uploadMethod, requiredHeaders } =
resolvePrepareUploadDetails(prepareUpload);
if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整");
}
const uploadHeaders = new Headers();
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
if (!headerName || headerValue === undefined || headerValue === null) continue;
uploadHeaders.set(headerName, String(headerValue));
}
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
}
const normalizedUploadMethod = uploadMethod.toUpperCase();
const uploadResponse = await fetch(uploadUrl, {
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
headers: uploadHeaders,
body: file,
cache: "no-store",
});
if (!uploadResponse.ok) {
const uploadError = await uploadResponse.text().catch(() => "");
throw new Error(
uploadError || `World Labs 文件上传失败(HTTP ${uploadResponse.status})`
);
}
logger.info("upload completed", {
elapsedMs: Date.now() - startedAt,
providerId: config.providerId || null,
kind: isImage ? "image" : "video",
size: file.size,
mediaAssetIdLength: mediaAssetId.length,
});
return NextResponse.json({
success: true,
data: {
mediaAssetId,
mediaAsset: prepareUpload.media_asset,
uploadInfo: prepareUpload.upload_info,
providerId: config.providerId || null,
providerName: config.providerName || null,
},
});
} catch (error) {
lastError = error;
if (isSignedUploadFailure(error)) {
try {
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
logger.warn("worldlabs signed upload failed; using storage source url", {
elapsedMs: Date.now() - startedAt,
providerId: config.providerId || null,
kind: isImage ? "image" : "video",
size: file.size,
});
return NextResponse.json({
success: true,
data: {
sourceUrl,
providerId: config.providerId || null,
providerName: config.providerName || null,
uploadFallback: "storage-source-url",
},
});
} catch (fallbackError) {
lastError = fallbackError;
}
}
if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure(
config.providerId,
error instanceof Error ? error.message : String(error)
);
}
if (requestedProviderId) {
break;
}
}
}
throw lastError instanceof Error
? lastError
: new Error("上传 World Labs 媒体失败");
} catch (error) {
logger.error("upload failed", error, {
elapsedMs: Date.now() - startedAt,
providerId: providerForLog,
kind: kindForLog,
size: sizeForLog,
});
if (error instanceof WorldLabsApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "上传 World Labs 媒体失败",
},
{ status: 500 }
);
}
}