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 { disableWorldLabsProvider, getWorldLabsProviderConfig, getWorldLabsProviderConfigs, } 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 = { 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 = { "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 isPermanentProviderError(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( 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 (isPermanentProviderError(error)) { await disableWorldLabsProvider(config.providerId, (error as Error).message); } 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 } ); } }