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/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<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智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2184,7 +2184,7 @@ export function SceneFusionView({
}
>
<input
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
onChange={handleProductUpload}
ref={productInputRef}
@ -2320,7 +2320,7 @@ export function SceneFusionView({
<Plus className="h-3 w-3" /> 批量添加
</Button>
<input
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
multiple
onChange={handleSceneUpload}
@ -2484,7 +2484,7 @@ export function SceneFusionView({
<Plus className="h-3 w-3" /> 批量添加
</Button>
<input
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
multiple
onChange={handleMultiProductUpload}
@ -2640,7 +2640,7 @@ export function SceneFusionView({
}
>
<input
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
onChange={handleReferenceSceneUpload}
ref={referenceSceneInputRef}
@ -2745,7 +2745,7 @@ export function SceneFusionView({
<Plus className="h-3 w-3" /> 批量添加
</Button>
<input
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
multiple
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`。
当前上传格式支持 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",
"framer-motion": "^11.18.2",
"geist": "^1.3.1",
"heic-convert": "^2.1.0",
"immer": "^11.1.3",
"is-hotkey": "^0.2.0",
"jszip": "^3.10.1",

38
pnpm-lock.yaml generated
View File

@ -220,6 +220,9 @@ importers:
geist:
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))
heic-convert:
specifier: ^2.1.0
version: 2.1.0
immer:
specifier: ^11.1.3
version: 11.1.3
@ -4691,6 +4694,14 @@ packages:
hastscript@9.0.1:
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:
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: {}

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