feat: convert heic uploads to jpeg
This commit is contained in:
parent
aa63415b1c
commit
7fd3feb77c
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 后再上传。
|
||||
|
||||
## 路由回归烟测
|
||||
|
||||
|
||||
@ -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
38
pnpm-lock.yaml
generated
@ -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
23
types/heic-convert.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user