204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
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 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;
|
||
|
||
function guessExtension(file: File) {
|
||
const fromName = String(file.name || "")
|
||
.split(".")
|
||
.pop()
|
||
?.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";
|
||
}
|
||
|
||
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) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const file = formData.get("file");
|
||
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);
|
||
|
||
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) {
|
||
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})`
|
||
);
|
||
}
|
||
|
||
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) {
|
||
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 }
|
||
);
|
||
}
|
||
}
|