398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|