From cb8297a3bf5485a5eb678478e8379448e4980a57 Mon Sep 17 00:00:00 2001 From: SPLATPLAYS Date: Mon, 23 Feb 2026 17:03:51 +0800 Subject: [PATCH] Add HEIC/HEIF image format support --- package.json | 1 + src/CommonFormats.ts | 15 ++++ src/handlers/heic.ts | 172 +++++++++++++++++++++++++++++++++++++++ src/handlers/index.ts | 2 + src/normalizeMimeType.ts | 2 + 5 files changed, 192 insertions(+) create mode 100644 src/handlers/heic.ts diff --git a/package.json b/package.json index c757dba1..cad0a257 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@types/pako": "^2.0.4", "@types/three": "^0.182.0", "bson": "^7.2.0", + "heic2any": "^0.0.4", "imagetracer": "^0.2.2", "js-synthesizer": "^1.11.0", "jszip": "^3.10.1", diff --git a/src/CommonFormats.ts b/src/CommonFormats.ts index f806b699..d6761edb 100644 --- a/src/CommonFormats.ts +++ b/src/CommonFormats.ts @@ -236,6 +236,21 @@ const CommonFormats = { "mxl", "application/vnd.recordare.musicxml", Category.DOCUMENT + ), + // high efficiency image + HEIC: new FormatDefinition( + "High Efficiency Image Container", + "heic", + "heic", + "image/heic", + Category.IMAGE + ), + HEIF: new FormatDefinition( + "High Efficiency Image File Format", + "heif", + "heif", + "image/heif", + Category.IMAGE ) } diff --git a/src/handlers/heic.ts b/src/handlers/heic.ts new file mode 100644 index 00000000..6576313e --- /dev/null +++ b/src/handlers/heic.ts @@ -0,0 +1,172 @@ +/** + * HEIC/HEIF Image Handler + * Handles High Efficiency Image Container (HEIC) and High Efficiency Image + * File Format (HEIF) — the modern image format used by Apple devices. + * + * Decoding (HEIC/HEIF → PNG/JPEG/WebP): + * Uses heic2any, a MIT-licensed WebAssembly-powered library. + * Supports single images and multi-frame sequences. + * + * Encoding (PNG/JPEG/WebP → HEIC/HEIF): + * Uses the browser's native HTMLCanvasElement.toBlob API. + * Native HEIC encoding works in Safari on macOS/iOS. Other browsers will + * throw a readable error message explaining the limitation. + */ + +// @ts-ignore — heic2any ships no TypeScript declarations +import heic2any from "heic2any"; + +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import CommonFormats from "src/CommonFormats.ts"; + +class HEICHandler implements FormatHandler { + + public name: string = "heic2any"; + public ready: boolean = false; + + public supportedFormats: FileFormat[] = [ + CommonFormats.HEIC.builder("heic").allowFrom().allowTo(), + CommonFormats.HEIF.builder("heif").allowFrom().allowTo(), + CommonFormats.PNG.builder("png").allowFrom().allowTo().markLossless(), + CommonFormats.JPEG.builder("jpeg").allowFrom().allowTo(), + CommonFormats.WEBP.builder("webp").allowFrom().allowTo(), + ]; + + async init(): Promise { + this.ready = true; + } + + async doConvert( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + + const isHeicInput = inputFormat.internal === "heic" || inputFormat.internal === "heif"; + const isHeicOutput = outputFormat.internal === "heic" || outputFormat.internal === "heif"; + + if (isHeicInput && !isHeicOutput) { + return this.#decodeHeic(inputFiles, inputFormat, outputFormat); + } + + if (!isHeicInput && isHeicOutput) { + return this.#encodeHeic(inputFiles, inputFormat, outputFormat); + } + + // HEIC ↔ HEIC passthrough (same format, no-op) + return inputFiles.map(f => ({ + name: f.name, + bytes: new Uint8Array(f.bytes), + })); + } + + /** + * Decode HEIC/HEIF → PNG / JPEG / WebP using heic2any. + */ + async #decodeHeic( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + + const outputFiles: FileData[] = []; + const toType = outputFormat.mime; + + for (const inputFile of inputFiles) { + const inputBlob = new Blob([inputFile.bytes as BlobPart], { type: inputFormat.mime }); + + let result: Blob | Blob[]; + try { + result = await heic2any({ blob: inputBlob, toType, quality: 0.92 }); + } catch (err: any) { + // heic2any throws when the file is not a valid HEIC/HEIF + throw new Error( + `Could not decode HEIC/HEIF file "${inputFile.name}": ${err?.message ?? err}` + ); + } + + const blobs: Blob[] = Array.isArray(result) ? result : [result]; + const baseName = inputFile.name.replace(/\.(heic|heif)$/i, ""); + + if (blobs.length === 1) { + const bytes = new Uint8Array(await blobs[0].arrayBuffer()); + outputFiles.push({ + name: `${baseName}.${outputFormat.extension}`, + bytes, + }); + } else { + // Multi-frame HEIC (e.g. burst photos) → one output file per frame + for (let i = 0; i < blobs.length; i++) { + const bytes = new Uint8Array(await blobs[i].arrayBuffer()); + outputFiles.push({ + name: `${baseName}_${String(i + 1).padStart(3, "0")}.${outputFormat.extension}`, + bytes, + }); + } + } + } + + return outputFiles; + } + + /** + * Encode PNG / JPEG / WebP → HEIC / HEIF using the browser canvas API. + * + * Native encoding is only supported in Safari on macOS/iOS (≥ High Sierra / + * iOS 11). Other browsers will reject the toBlob call and throw a clear + * error message rather than silently producing a corrupt file. + */ + async #encodeHeic( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + + const outputFiles: FileData[] = []; + + for (const inputFile of inputFiles) { + const inputBlob = new Blob([inputFile.bytes as BlobPart], { type: inputFormat.mime }); + + // Decode the source image via ImageBitmap for universal browser support + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(inputBlob); + } catch (err: any) { + throw new Error( + `Could not decode source image "${inputFile.name}": ${err?.message ?? err}` + ); + } + + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to obtain 2D canvas context."); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + + const outputBlob = await new Promise(resolve => + canvas.toBlob(resolve, outputFormat.mime, 0.92) + ); + + if (!outputBlob) { + throw new Error( + `HEIC/HEIF encoding is not supported in this browser. ` + + `Native encoding requires Safari on macOS (High Sierra or later) or iOS (11 or later). ` + + `Consider converting to PNG or JPEG instead.` + ); + } + + const baseName = inputFile.name.replace(/\.[^.]+$/, ""); + outputFiles.push({ + name: `${baseName}.${outputFormat.extension}`, + bytes: new Uint8Array(await outputBlob.arrayBuffer()), + }); + } + + return outputFiles; + } +} + +export default HEICHandler; diff --git a/src/handlers/index.ts b/src/handlers/index.ts index e1798806..9451f002 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -47,6 +47,7 @@ import bsonHandler from "./bson.ts"; import asepriteHandler from "./aseprite.ts"; import vexflowHandler from "./vexflow.ts"; import toonHandler from "./toon.ts"; +import heicHandler from "./heic.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -99,5 +100,6 @@ try { handlers.push(new bsonHandler()) } catch (_) { }; try { handlers.push(new asepriteHandler()) } catch (_) { }; try { handlers.push(new vexflowHandler()) } catch (_) { }; try { handlers.push(new toonHandler()) } catch (_) { }; +try { handlers.push(new heicHandler()) } catch (_) { }; export default handlers; diff --git a/src/normalizeMimeType.ts b/src/normalizeMimeType.ts index 048316ea..ae7fc9fb 100644 --- a/src/normalizeMimeType.ts +++ b/src/normalizeMimeType.ts @@ -30,6 +30,8 @@ function normalizeMimeType (mime: string) { case "application/musicxml": return "application/vnd.recordare.musicxml+xml"; case "application/musicxml+xml": return "application/vnd.recordare.musicxml+xml"; case "text/mathml": return "application/mathml+xml"; + case "image/heic-sequence": return "image/heic"; + case "image/heif-sequence": return "image/heif"; } return mime; }