From 7fd3feb77cc951611f81fde0db7971f826bf22c7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 28 May 2026 22:46:41 +0800 Subject: [PATCH] feat: convert heic uploads to jpeg --- app/(chat)/api/files/upload/route.ts | 89 +++++++++++++++++-- app/canvas/[id]/model3d/page.tsx | 4 +- .../agents/AgentPsdTemplateEditorDialog.tsx | 2 +- .../agents/AgentWorkflowMaterialPicker.tsx | 4 +- components/canvas/nodes/ImageNode.tsx | 2 +- components/image-annotator.tsx | 2 +- components/scene-fusion-view.tsx | 10 +-- docs/operations.md | 2 +- package.json | 1 + pnpm-lock.yaml | 38 ++++++++ types/heic-convert.d.ts | 23 +++++ 11 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 types/heic-convert.d.ts diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index 4ddb1f601..6c11efe0b 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -15,8 +15,12 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([ "image/png", "image/webp", "image/svg+xml", + "image/heic", + "image/heif", ]); +const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]); + function normalizeUploadContentType(contentType: string, filename: string) { const normalized = contentType.trim().toLowerCase(); if (normalized && normalized !== "application/octet-stream") { @@ -29,11 +33,44 @@ function normalizeUploadContentType(contentType: string, filename: string) { 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 "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智能优化图片(保持原格式) * 策略:避免双重压缩,只在需要时优化 @@ -152,8 +189,13 @@ export async function POST(request: Request) { } // Get filename from formData since Blob doesn't have name property - const filename = (formData.get("file") as File).name; - const contentType = normalizeUploadContentType(file.type, filename); + 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 clientUploadId = String(formData.get("clientUploadId") || "").trim() || null; @@ -203,8 +245,42 @@ export async function POST(request: Request) { logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength); try { + let uploadBuffer: Buffer = Buffer.from(fileBuffer); + let convertedFrom: string | null = null; + + if (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(Buffer.from(fileBuffer)); + const preview = await createAgentWorkflowPreviewImageBuffer(uploadBuffer); const previewResult = await uploadFile( `${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`, toArrayBuffer(preview.buffer) @@ -231,6 +307,7 @@ export async function POST(request: Request) { previewSize: preview.buffer.length, purpose: uploadPurpose, clientUploadId, + convertedFrom, }, }); assetId = asset?.id ?? null; @@ -253,7 +330,7 @@ export async function POST(request: Request) { } // ✅ 使用Sharp优化图片(保持原格式) - let optimizedBuffer: Buffer = Buffer.from(fileBuffer); + let optimizedBuffer: Buffer = uploadBuffer; // ✅ 优化原图(保持JPG/PNG格式) // WebP格式不优化(用于预览图,保持原样) @@ -297,6 +374,7 @@ export async function POST(request: Request) { pathname: data.pathname, size: optimizedBuffer.length, clientUploadId, + convertedFrom, }, }); assetId = asset?.id ?? null; @@ -309,6 +387,7 @@ export async function POST(request: Request) { ...data, assetId, clientUploadId, + convertedFrom, }); } catch (_error) { logger.error("[Upload API] Upload failed:", _error); diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 08216c57d..87d2fcaec 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -3202,7 +3202,7 @@ export default function CanvasModel3DPage() { )} setWorldImageFile(e.target.files?.[0] || null) @@ -3236,7 +3236,7 @@ export default function CanvasModel3DPage() { )} handleWorldImageSlotChange( diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 58d090056..2e8dbb167 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -4799,7 +4799,7 @@ export function AgentPsdTemplateEditorDialog({ ) : null} { if (!selectedImageLayer) return; diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index 5da52758b..2f8f27955 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -759,7 +759,7 @@ export function AgentWorkflowMaterialPicker({ > { @@ -806,7 +806,7 @@ export function AgentWorkflowMaterialPicker({ > { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index cfc33b3a5..930918c03 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -7452,7 +7452,7 @@ export function ImageNode({ )} 批量添加 批量添加 批量添加 =12.0.0'} + + heic-decode@2.1.0: + resolution: {integrity: sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==} + engines: {node: '>=8.0.0'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -4843,6 +4854,9 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-cookie@3.0.7: resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} engines: {node: '>=20'} @@ -4929,6 +4943,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + libheif-js@1.19.8: + resolution: {integrity: sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==} + engines: {node: '>=8.0.0'} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -5658,6 +5676,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -11298,6 +11320,16 @@ snapshots: property-information: 7.0.0 space-separated-tokens: 2.0.2 + heic-convert@2.1.0: + dependencies: + heic-decode: 2.1.0 + jpeg-js: 0.4.4 + pngjs: 6.0.0 + + heic-decode@2.1.0: + dependencies: + libheif-js: 1.19.8 + highlight.js@10.7.3: {} highlightjs-vue@1.0.0: {} @@ -11425,6 +11457,8 @@ snapshots: jose@6.1.3: {} + jpeg-js@0.4.4: {} + js-cookie@3.0.7: {} js-md4@0.3.2: {} @@ -11508,6 +11542,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + libheif-js@1.19.8: {} + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -12541,6 +12577,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@6.0.0: {} + points-on-curve@0.2.0: {} points-on-curve@1.0.1: {} diff --git a/types/heic-convert.d.ts b/types/heic-convert.d.ts new file mode 100644 index 000000000..5587cadf8 --- /dev/null +++ b/types/heic-convert.d.ts @@ -0,0 +1,23 @@ +declare module "heic-convert" { + type ConvertOptions = { + buffer: Buffer | ArrayBuffer | Uint8Array; + format: "JPEG" | "PNG"; + quality?: number; + }; + + type ConvertAllOptions = ConvertOptions; + + type ConvertibleImage = { + convert: () => Promise; + }; + + function convert( + options: ConvertOptions + ): Promise; + + namespace convert { + function all(options: ConvertAllOptions): Promise; + } + + export = convert; +}