620 lines
23 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 { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger";
import {
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
import {
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
} from "@/lib/video-canvas/subjects";
import {
VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE,
VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE,
} from "@/lib/video-canvas/uploaded-resource";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
]);
const ALLOWED_UPLOAD_AUDIO_TYPES = new Set([
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/wav",
"audio/x-wav",
"audio/webm",
"audio/ogg",
"audio/aac",
]);
const ALLOWED_UPLOAD_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
const GAUSSIAN_SPLAT_EXTENSION_RE = /\.(ply|splat|spz|ksplat)$/i;
function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.split(";", 1)[0].trim().toLowerCase();
if (normalized && normalized !== "application/octet-stream") {
return normalized;
}
return getContentType(filename);
}
function getAcceptedMimeTypes(accept: string) {
return new Set(
accept
.split(",")
.map((item) => item.trim().toLowerCase())
.filter((item) => item.includes("/") && !item.endsWith("/*")),
);
}
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "unknown";
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
}
return "File type should be JPEG, PNG, WebP or SVG";
}
function isHeicUpload(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "";
return (
HEIC_UPLOAD_TYPES.has(contentType) ||
extension === "heic" ||
extension === "heif"
);
}
function toJpegFilename(filename: string) {
const trimmed = filename.trim() || `upload-${Date.now()}`;
if (/\.(heic|heif)$/i.test(trimmed)) {
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
}
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
}
return `${trimmed}.jpg`;
}
async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
const convert = (await import("heic-convert")).default;
const output = await convert({
buffer,
format: "JPEG",
quality: 0.92,
});
if (output instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(output));
}
return Buffer.from(output);
}
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
* - 如果文件已经很小(<3MB),跳过优化(客户端已处理)
* - 如果文件较大,使用MozJPEG/PNG优化算法进一步优化
*/
async function optimizeImage(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const fileSizeMB = buffer.length / 1024 / 1024;
const OPTIMIZE_THRESHOLD_MB = 3; // 3MB阈值:小于此值不优化(避免双重压缩)
// ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩)
if (fileSizeMB < OPTIMIZE_THRESHOLD_MB) {
logger.debug(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`);
return buffer;
}
const sharp = await import("sharp");
const image = sharp.default(buffer);
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
// ✅ 使用MozJPEG编码器,高质量压缩
// 注意:这里使用MozJPEG重新编码,使用更好的算法,而不是降低质量
const optimized = await image
.jpeg({
quality: 92, // 高质量(92%质量,画质优秀)
mozjpeg: true, // 使用MozJPEG编码器(比标准JPEG压缩率高15-25%,但质量更好)
progressive: true, // 渐进式JPEG,加载体验更好
optimizeScans: true, // 优化扫描
trellisQuantisation: true, // 网格量化,更好的压缩
overshootDeringing: true, // 过冲去振铃,减少伪影
optimizeCoding: true, // 优化编码
})
.toBuffer();
// 只有优化后文件明显更小才使用(避免不必要的处理)
const reductionPercent = ((1 - optimized.length / buffer.length) * 100);
if (reductionPercent > 5) { // 只有减少超过5%才使用
logger.debug(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
return optimized;
} else {
logger.debug(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
return buffer;
}
} else if (mimeType === 'image/png') {
// ✅ PNG优化压缩(无损优化)
const metadata = await image.metadata();
const optimized = await image
.png({
compressionLevel: 6, // 压缩级别(0-9,6是平衡点)
adaptiveFiltering: true, // 自适应过滤,减少文件大小
palette: !metadata.hasAlpha, // 无透明通道时使用调色板模式
quality: 100, // PNG质量(100=无损)
effort: 6, // 压缩努力程度(0-10,6是平衡点)
})
.toBuffer();
// 只有优化后文件明显更小才使用
const reductionPercent = ((1 - optimized.length / buffer.length) * 100);
if (reductionPercent > 5) { // 只有减少超过5%才使用
logger.debug(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`);
return optimized;
} else {
logger.debug(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`);
return buffer;
}
}
// 其他格式返回原图
return buffer;
} catch (error) {
logger.error("[Upload] 图片优化失败,使用原图:", error);
// 优化失败时返回原图,不影响上传流程
return buffer;
}
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
return view.slice().buffer;
}
// Use Blob instead of File since File is not available in Node.js environment
function createFileSchema(maxBytes: number, maxSizeLabel: string) {
return z.object({
file: z.instanceof(Blob).refine((value) => value.size <= maxBytes, {
message: `File size should be less than ${maxSizeLabel}`,
}),
});
}
export async function POST(request: Request) {
logger.debug("[Upload API] Received upload request");
if (request.body === null) {
logger.debug("[Upload API] Request body is empty");
return new Response("Request body is empty", { status: 400 });
}
try {
const formData = await request.formData();
const file = formData.get("file") as Blob;
logger.debug("[Upload API] FormData received, file exists:", !!file);
if (!file) {
logger.debug("[Upload API] No file in FormData");
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const originalFilename = (formData.get("file") as File).name;
const originalContentType = normalizeUploadContentType(
file.type,
originalFilename
);
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
const isDirectorGaussianUpload = uploadPurpose === "director-gaussian";
const isCanvasAudioUpload = uploadPurpose === "canvas-audio-process";
const isCanvasVideoUpload = uploadPurpose === "canvas-video-process";
const isOpenClawChatVideoUpload =
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE;
const isVideoCanvasAssetLibraryUpload =
uploadPurpose === "video-canvas-asset-library";
const isVideoCanvasResourceUpload =
uploadPurpose === VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE;
const isVideoCanvasSubjectUpload =
uploadPurpose === "video-canvas-subject";
const isVideoCanvasSubjectVoiceUpload =
uploadPurpose === "video-canvas-subject-voice";
const videoCanvasSetting =
isDirectorGaussianUpload ||
isVideoCanvasResourceUpload ||
isVideoCanvasSubjectUpload
? await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
() => undefined,
)
: undefined;
const videoCanvasConfig = videoCanvasSetting
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
: defaultVideoCanvasFunctionConfig;
const gaussianConfig = videoCanvasConfig.directorDesk.gaussian;
const subjectLibraryConfig = videoCanvasConfig.subjectLibrary;
if (
isDirectorGaussianUpload &&
!GAUSSIAN_SPLAT_EXTENSION_RE.test(originalFilename)
) {
return NextResponse.json(
{ error: `高斯泼溅仅支持 ${gaussianConfig.uploadAccept}` },
{ status: 400 },
);
}
if (isVideoCanvasSubjectUpload) {
const acceptedTypes = new Set([
...getAcceptedMimeTypes(subjectLibraryConfig.imageAccept),
...getAcceptedMimeTypes(subjectLibraryConfig.videoAccept),
]);
if (!acceptedTypes.has(originalContentType)) {
return NextResponse.json(
{ error: "主体素材仅支持 JPG、PNG、WebP、MP4 或 MOV" },
{ status: 400 },
);
}
}
if (
isVideoCanvasSubjectVoiceUpload &&
!ALLOWED_UPLOAD_AUDIO_TYPES.has(originalContentType) &&
!ALLOWED_UPLOAD_VIDEO_TYPES.has(originalContentType)
) {
return NextResponse.json(
{ error: "音色素材仅支持 MP3、WAV、MP4 或 MOV" },
{ status: 400 },
);
}
const requestHostname = new URL(request.url).hostname.toLowerCase();
const isLoopbackRequest =
requestHostname === "localhost" ||
requestHostname === "127.0.0.1" ||
requestHostname === "::1";
const isLocalDevelopmentCanvasUpload =
(process.env.NODE_ENV !== "production" || isLoopbackRequest) &&
process.env.USE_REMOTE_STORAGE_IN_DEV !== "true" &&
(uploadPurpose === "canvas-upload" ||
isCanvasAudioUpload ||
isCanvasVideoUpload ||
isOpenClawChatVideoUpload ||
isDirectorGaussianUpload);
const session = isLocalDevelopmentCanvasUpload ? null : await auth();
if (!isLocalDevelopmentCanvasUpload && !session) {
logger.debug("[Upload API] Unauthorized - no session");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
if (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session?.user.type))
) {
return NextResponse.json(
{ error: "SVG uploads are only allowed for administrator icon settings" },
{ status: 403 }
);
}
logger.debug("[Upload API] File info:", {
type: contentType,
size: file.size,
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
});
const maxFileSizeMb = isDirectorGaussianUpload
? gaussianConfig.maxFileSizeMb
: isVideoCanvasResourceUpload
? videoCanvasConfig.nodeCreation.upload.maxFileSizeMb
: isVideoCanvasSubjectUpload
? originalContentType.startsWith("video/")
? subjectLibraryConfig.videoMaxFileSizeMb
: subjectLibraryConfig.imageMaxFileSizeMb
: 50;
const validatedFile = createFileSchema(
maxFileSizeMb * 1024 * 1024,
`${maxFileSizeMb}MB`,
).safeParse({ file });
if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
.map((error) => error.message)
.join(", ");
logger.debug("[Upload API] Validation failed:", errorMessage);
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
if (
!isDirectorGaussianUpload &&
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!(isCanvasAudioUpload && ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType)) &&
!(isCanvasVideoUpload && ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)) &&
!(
isOpenClawChatVideoUpload &&
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
) &&
!(
isVideoCanvasAssetLibraryUpload &&
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
) &&
!(
isVideoCanvasResourceUpload &&
(ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) ||
ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
) &&
!(
isVideoCanvasSubjectUpload &&
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
) &&
!(
isVideoCanvasSubjectVoiceUpload &&
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
)
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
filenameExtension: filename.toLowerCase().split(".").pop() || null,
size: file.size,
purpose: uploadPurpose || null,
clientUploadId,
});
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
logger.debug("[Upload API] Filename:", filename);
const fileBuffer = await file.arrayBuffer();
logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength);
try {
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
let convertedFrom: string | null = null;
if (!isDirectorGaussianUpload && isHeicUpload(contentType, filename)) {
try {
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
logger.info("[Upload API] HEIC image converted to JPEG", {
clientUploadId,
originalContentType: contentType,
originalSize: uploadBuffer.length,
convertedSize: convertedBuffer.length,
});
uploadBuffer = convertedBuffer;
filename = toJpegFilename(filename);
convertedFrom = contentType;
contentType = "image/jpeg";
} catch (heicError) {
logger.warn("[Upload API] HEIC image conversion failed", {
clientUploadId,
contentType,
size: uploadBuffer.length,
error:
heicError instanceof Error ? heicError.message : String(heicError),
});
return NextResponse.json(
{
error: createUnsupportedImageTypeMessage(contentType, filename),
clientUploadId,
},
{ status: 400 }
);
}
}
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
const preview = await createAgentWorkflowPreviewImageBuffer(uploadBuffer);
const previewResult = await uploadFile(
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
toArrayBuffer(preview.buffer)
);
let assetId: string | null = null;
try {
const asset = await upsertMediaAsset({
userId: session?.user.id || "",
mediaType: "image",
sourceType: "agent_workflow_preview_upload",
status: "ready",
originalUrl: previewResult.url,
previewUrl: previewResult.url,
thumbnailUrl: previewResult.url,
mimeType: "image/webp",
width: preview.width,
height: preview.height,
displayName: filename,
sourceTable: "agent_workflow_preview_upload",
metadata: {
pathname: previewResult.pathname,
originalSize: fileBuffer.byteLength,
previewSize: preview.buffer.length,
purpose: uploadPurpose,
clientUploadId,
convertedFrom,
},
});
assetId = asset?.id ?? null;
} catch (assetError) {
logger.warn("[Upload API] Failed to register workflow preview asset:", assetError);
}
return NextResponse.json({
...previewResult,
url: previewResult.url,
previewUrl: previewResult.url,
thumbnailUrl: previewResult.url,
originalUrl: previewResult.url,
previewImageSourceUrl: previewResult.url,
assetId,
width: preview.width,
height: preview.height,
optimizedFor: uploadPurpose,
});
}
// ✅ 使用Sharp优化图片(保持原格式)
let optimizedBuffer: Buffer = uploadBuffer;
// ✅ 优化原图(保持JPG/PNG格式)
// WebP格式不优化(用于预览图,保持原样)
const shouldOptimizeImage =
!isCanvasAudioUpload &&
!isCanvasVideoUpload &&
uploadPurpose !== "canvas-upload" &&
!isDirectorGaussianUpload &&
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
if (shouldOptimizeImage) {
optimizedBuffer = await optimizeImage(optimizedBuffer, contentType);
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
} else if (
uploadPurpose === "canvas-upload" ||
isCanvasAudioUpload ||
isCanvasVideoUpload ||
isOpenClawChatVideoUpload ||
isDirectorGaussianUpload
) {
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
}
// 将Buffer转换为ArrayBuffer用于上传
const optimizedArrayBuffer = optimizedBuffer.buffer.slice(
optimizedBuffer.byteOffset,
optimizedBuffer.byteOffset + optimizedBuffer.byteLength
) as ArrayBuffer;
// 使用统一的存储服务(支持数据库配置的 S3、环境变量 S3 或 Vercel Blob)
// 使用优化后的buffer上传
const data = await uploadFile(filename, optimizedArrayBuffer);
let assetId: string | null = null;
const isLocalDevelopmentUpload = data.pathname.startsWith("uploads/local/");
if (
!isLocalDevelopmentUpload ||
isVideoCanvasResourceUpload ||
isVideoCanvasAssetLibraryUpload ||
isVideoCanvasSubjectUpload ||
isVideoCanvasSubjectVoiceUpload
) {
try {
const uploadedMediaType = isDirectorGaussianUpload
? "model"
: isCanvasAudioUpload || contentType.startsWith("audio/")
? "audio"
: isCanvasVideoUpload || contentType.startsWith("video/")
? "video"
: "image";
const asset = await upsertMediaAsset({
userId: session?.user.id || "",
mediaType: uploadedMediaType,
sourceType: isDirectorGaussianUpload
? "director_gaussian_upload"
: isCanvasAudioUpload
? "canvas_audio_process"
: isCanvasVideoUpload
? "canvas_video_process"
: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: isVideoCanvasResourceUpload
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
: isVideoCanvasAssetLibraryUpload
? "video_canvas_asset_library_upload"
: isVideoCanvasSubjectUpload
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
: isVideoCanvasSubjectVoiceUpload
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: isDirectorGaussianUpload ? null : data.url,
thumbnailUrl: isDirectorGaussianUpload ? null : data.url,
mimeType: contentType || data.contentType || null,
displayName: filename,
sourceTable: isDirectorGaussianUpload
? "director_gaussian_upload"
: isCanvasAudioUpload
? "canvas_audio_process"
: isCanvasVideoUpload
? "canvas_video_process"
: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: isVideoCanvasResourceUpload
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
: isVideoCanvasSubjectUpload
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
: isVideoCanvasSubjectVoiceUpload
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
clientUploadId,
convertedFrom,
purpose: uploadPurpose || null,
category:
String(formData.get("assetCategory") || "").trim() || null,
},
});
assetId = asset?.id ?? null;
} catch (assetError) {
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
}
}
logger.debug("[Upload API] Upload successful:", data.url);
return NextResponse.json({
...data,
contentType,
assetId,
clientUploadId,
convertedFrom,
});
} catch (_error) {
logger.error("[Upload API] Upload failed:", _error);
return NextResponse.json({
error: "Upload failed",
details: _error instanceof Error ? _error.message : String(_error)
}, { status: 500 });
}
} catch (_error) {
logger.error("[Upload API] Request processing failed:", _error);
return NextResponse.json(
{
error: "Failed to process request",
details: _error instanceof Error ? _error.message : String(_error)
},
{ status: 500 }
);
}
}