297 lines
9.4 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 { 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 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 = String(prepareUpload.upload_info?.upload_url || "").trim();
if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整");
}
const uploadHeaders = new Headers();
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
if (!headerName || headerValue === undefined || headerValue === null) continue;
uploadHeaders.set(headerName, String(headerValue));
}
const uploadMethod = String(
prepareUpload.upload_info?.upload_method || "PUT"
).toUpperCase();
const uploadResponse = await fetch(uploadUrl, {
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
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 (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 }
);
}
}