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 { 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 { 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 } ); } }