297 lines
9.4 KiB
TypeScript
297 lines
9.4 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|