feat: convert heic uploads to jpeg

This commit is contained in:
Your Name 2026-05-28 22:46:41 +08:00
parent aa63415b1c
commit 7fd3feb77c
11 changed files with 159 additions and 18 deletions

View File

@ -15,8 +15,12 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/png", "image/png",
"image/webp", "image/webp",
"image/svg+xml", "image/svg+xml",
"image/heic",
"image/heif",
]); ]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
function normalizeUploadContentType(contentType: string, filename: string) { function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase(); const normalized = contentType.trim().toLowerCase();
if (normalized && normalized !== "application/octet-stream") { if (normalized && normalized !== "application/octet-stream") {
@ -29,11 +33,44 @@ function normalizeUploadContentType(contentType: string, filename: string) {
function createUnsupportedImageTypeMessage(contentType: string, filename: string) { function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "unknown"; const extension = filename.toLowerCase().split(".").pop() || "unknown";
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") { 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"; 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智能优化图片(保持原格式) * 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化 * 策略:避免双重压缩,只在需要时优化
@ -152,8 +189,13 @@ export async function POST(request: Request) {
} }
// Get filename from formData since Blob doesn't have name property // Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name; const originalFilename = (formData.get("file") as File).name;
const contentType = normalizeUploadContentType(file.type, filename); const originalContentType = normalizeUploadContentType(
file.type,
originalFilename
);
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || ""); const uploadPurpose = String(formData.get("purpose") || "");
const clientUploadId = const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null; 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); logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength);
try { 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") { 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( const previewResult = await uploadFile(
`${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`, `${filename.replace(/\.[^.]+$/, "") || "agent-workflow-preview"}.webp`,
toArrayBuffer(preview.buffer) toArrayBuffer(preview.buffer)
@ -231,6 +307,7 @@ export async function POST(request: Request) {
previewSize: preview.buffer.length, previewSize: preview.buffer.length,
purpose: uploadPurpose, purpose: uploadPurpose,
clientUploadId, clientUploadId,
convertedFrom,
}, },
}); });
assetId = asset?.id ?? null; assetId = asset?.id ?? null;
@ -253,7 +330,7 @@ export async function POST(request: Request) {
} }
// ✅ 使用Sharp优化图片(保持原格式) // ✅ 使用Sharp优化图片(保持原格式)
let optimizedBuffer: Buffer = Buffer.from(fileBuffer); let optimizedBuffer: Buffer = uploadBuffer;
// ✅ 优化原图(保持JPG/PNG格式) // ✅ 优化原图(保持JPG/PNG格式)
// WebP格式不优化(用于预览图,保持原样) // WebP格式不优化(用于预览图,保持原样)
@ -297,6 +374,7 @@ export async function POST(request: Request) {
pathname: data.pathname, pathname: data.pathname,
size: optimizedBuffer.length, size: optimizedBuffer.length,
clientUploadId, clientUploadId,
convertedFrom,
}, },
}); });
assetId = asset?.id ?? null; assetId = asset?.id ?? null;
@ -309,6 +387,7 @@ export async function POST(request: Request) {
...data, ...data,
assetId, assetId,
clientUploadId, clientUploadId,
convertedFrom,
}); });
} catch (_error) { } catch (_error) {
logger.error("[Upload API] Upload failed:", _error); logger.error("[Upload API] Upload failed:", _error);

View File

@ -3202,7 +3202,7 @@ export default function CanvasModel3DPage() {
</> </>
)} )}
<Input <Input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
onChange={(e) => onChange={(e) =>
setWorldImageFile(e.target.files?.[0] || null) setWorldImageFile(e.target.files?.[0] || null)
@ -3236,7 +3236,7 @@ export default function CanvasModel3DPage() {
</span> </span>
)} )}
<Input <Input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
onChange={(e) => onChange={(e) =>
handleWorldImageSlotChange( handleWorldImageSlotChange(

View File

@ -4799,7 +4799,7 @@ export function AgentPsdTemplateEditorDialog({
) : null} ) : null}
<Input <Input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
onChange={(event) => { onChange={(event) => {
if (!selectedImageLayer) return; if (!selectedImageLayer) return;

View File

@ -759,7 +759,7 @@ export function AgentWorkflowMaterialPicker({
> >
<input <input
type="file" type="file"
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
disabled={isReplacing || isUploadingNewImage || isLoading} disabled={isReplacing || isUploadingNewImage || isLoading}
onChange={(event) => { onChange={(event) => {
@ -806,7 +806,7 @@ export function AgentWorkflowMaterialPicker({
> >
<input <input
type="file" type="file"
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
disabled={isUploadingNewImage || Boolean(uploadingImageId) || isLoading} disabled={isUploadingNewImage || Boolean(uploadingImageId) || isLoading}
onChange={(event) => { onChange={(event) => {

View File

@ -7452,7 +7452,7 @@ export function ImageNode({
</button> </button>
)} )}
<input <input
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
data-canvas-no-drag="true" data-canvas-no-drag="true"
data-local-edit-control="true" data-local-edit-control="true"

View File

@ -1985,7 +1985,7 @@ export function ImageAnnotator({
</p> </p>
<Button variant="default">选择图片</Button> <Button variant="default">选择图片</Button>
<input <input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
onChange={handleImageUpload} onChange={handleImageUpload}
ref={fileInputRef} ref={fileInputRef}

View File

@ -2184,7 +2184,7 @@ export function SceneFusionView({
} }
> >
<input <input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
onChange={handleProductUpload} onChange={handleProductUpload}
ref={productInputRef} ref={productInputRef}
@ -2320,7 +2320,7 @@ export function SceneFusionView({
<Plus className="h-3 w-3" /> 批量添加 <Plus className="h-3 w-3" /> 批量添加
</Button> </Button>
<input <input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
multiple multiple
onChange={handleSceneUpload} onChange={handleSceneUpload}
@ -2484,7 +2484,7 @@ export function SceneFusionView({
<Plus className="h-3 w-3" /> 批量添加 <Plus className="h-3 w-3" /> 批量添加
</Button> </Button>
<input <input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
multiple multiple
onChange={handleMultiProductUpload} onChange={handleMultiProductUpload}
@ -2640,7 +2640,7 @@ export function SceneFusionView({
} }
> >
<input <input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
onChange={handleReferenceSceneUpload} onChange={handleReferenceSceneUpload}
ref={referenceSceneInputRef} ref={referenceSceneInputRef}
@ -2745,7 +2745,7 @@ export function SceneFusionView({
<Plus className="h-3 w-3" /> 批量添加 <Plus className="h-3 w-3" /> 批量添加
</Button> </Button>
<input <input
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" className="hidden"
multiple multiple
onChange={handleBatchUpload} onChange={handleBatchUpload}

View File

@ -183,7 +183,7 @@ DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS=2 pnpm storage:configure-upload-lifecycle
默认只清理 `uploads/direct/` 前缀下 2 天未完成的分片;如使用自定义前缀,可设置 `DIRECT_UPLOAD_LIFECYCLE_PREFIX`。 默认只清理 `uploads/direct/` 前缀下 2 天未完成的分片;如使用自定义前缀,可设置 `DIRECT_UPLOAD_LIFECYCLE_PREFIX`。
当前上传格式支持 JPEG、PNG、WebP,管理员图标额外允许 SVG。HEIC/HEIF 不是可靠的 Web 展示格式,当前会明确提示用户转为 JPG/PNG;若未来要原生支持,应作为独立专项引入客户端或服务端转码,并评估前端包体、Sharp/libvips HEIF 支持和隐私成本。 当前上传格式支持 JPEG、PNG、WebP,管理员图标额外允许 SVG。HEIC/HEIF 上传会在服务端自动转为高质量 JPEG,避免把转码 WASM 放进前端主包;如果转码失败,会明确提示用户从手机相册导出 JPG/PNG 后再上传。
## 路由回归烟测 ## 路由回归烟测

View File

@ -115,6 +115,7 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"geist": "^1.3.1", "geist": "^1.3.1",
"heic-convert": "^2.1.0",
"immer": "^11.1.3", "immer": "^11.1.3",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",

38
pnpm-lock.yaml generated
View File

@ -220,6 +220,9 @@ importers:
geist: geist:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)) version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
heic-convert:
specifier: ^2.1.0
version: 2.1.0
immer: immer:
specifier: ^11.1.3 specifier: ^11.1.3
version: 11.1.3 version: 11.1.3
@ -4691,6 +4694,14 @@ packages:
hastscript@9.0.1: hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
heic-convert@2.1.0:
resolution: {integrity: sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==}
engines: {node: '>=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: highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@ -4843,6 +4854,9 @@ packages:
jose@6.1.3: jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
js-cookie@3.0.7: js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -4929,6 +4943,10 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'} 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: lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@ -5658,6 +5676,10 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
pngjs@6.0.0:
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
engines: {node: '>=12.13.0'}
points-on-curve@0.2.0: points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@ -11298,6 +11320,16 @@ snapshots:
property-information: 7.0.0 property-information: 7.0.0
space-separated-tokens: 2.0.2 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: {} highlight.js@10.7.3: {}
highlightjs-vue@1.0.0: {} highlightjs-vue@1.0.0: {}
@ -11425,6 +11457,8 @@ snapshots:
jose@6.1.3: {} jose@6.1.3: {}
jpeg-js@0.4.4: {}
js-cookie@3.0.7: {} js-cookie@3.0.7: {}
js-md4@0.3.2: {} js-md4@0.3.2: {}
@ -11508,6 +11542,8 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
libheif-js@1.19.8: {}
lie@3.3.0: lie@3.3.0:
dependencies: dependencies:
immediate: 3.0.6 immediate: 3.0.6
@ -12541,6 +12577,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
pngjs@6.0.0: {}
points-on-curve@0.2.0: {} points-on-curve@0.2.0: {}
points-on-curve@1.0.1: {} points-on-curve@1.0.1: {}

23
types/heic-convert.d.ts vendored Normal file
View File

@ -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<ArrayBuffer | Uint8Array | Buffer>;
};
function convert(
options: ConvertOptions
): Promise<ArrayBuffer | Uint8Array | Buffer>;
namespace convert {
function all(options: ConvertAllOptions): Promise<ConvertibleImage[]>;
}
export = convert;
}