feat: add HEIC/HEIF format support for input and output

Add bidirectional HEIC support using system libheif CLI tools (heif-enc/heif-dec)
for HEVC encoding/decoding, since Sharp's bundled libheif only supports AV1.

- HEIC input: all tools now accept iPhone HEIC photos via heif-dec pre-processing
- HEIC output: convert tool produces true HEIC (HEVC) via heif-enc
- Docker: adds libheif-examples package for heif-enc/heif-dec CLI tools
- Tests: full conversion matrix (7x7), unit tests, and Playwright e2e tests
- Docs: updated OpenAPI spec, image-engine docs, getting-started, llms-full.txt
This commit is contained in:
Siddharth Kumar Sah
2026-04-04 21:33:48 +08:00
parent 84283c16bd
commit 6717c17a26
21 changed files with 193 additions and 20 deletions
+8
View File
@@ -11,6 +11,7 @@ const SUPPORTED_INPUT_FORMATS = new Set([
"tiff",
"bmp",
"avif",
"heif",
"svg",
]);
@@ -29,6 +30,7 @@ const MAGIC_BYTES: MagicEntry[] = [
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" },
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "heif" }, // ftyp box; verified below
];
export interface ValidationResult {
@@ -152,6 +154,12 @@ function detectMagicBytes(buffer: Buffer): string | null {
const brand = buffer.slice(8, 12).toString("ascii");
if (brand !== "avif" && brand !== "avis") continue;
}
// For ftyp, verify HEIF/HEIC brand at bytes 8-11
if (entry.format === "heif") {
if (buffer.length < 12) continue;
const brand = buffer.slice(8, 12).toString("ascii");
if (brand !== "heic" && brand !== "heix" && brand !== "mif1") continue;
}
return entry.format;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Decode a HEIC/HEIF buffer to PNG using the system `heif-dec` CLI tool.
* This is needed because Sharp's bundled libheif does not include the
* HEVC decoder required for true HEIC files (iPhone photos).
*/
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await execFileAsync("heif-dec", [inputPath, outputPath], { timeout: 30_000 });
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
/**
* Encode a PNG/JPEG buffer to HEIC using the system `heif-enc` CLI tool.
* Uses x265 (HEVC) compression for true HEIC output.
*/
export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `heic-in-${id}.png`);
const outputPath = join(tmpdir(), `heic-out-${id}.heic`);
try {
await writeFile(inputPath, buffer);
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
timeout: 30_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
/**
* Check whether the heif-enc and heif-dec CLI tools are available.
*/
export async function isHeicToolAvailable(): Promise<boolean> {
try {
await execFileAsync("heif-enc", ["--version"], { timeout: 5_000 });
await execFileAsync("heif-dec", ["--version"], { timeout: 5_000 });
return true;
} catch {
return false;
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ paths:
type: string
description: |
JSON string with options:
- `format` (string, required) — One of: jpg, png, webp, avif, tiff, gif
- `format` (string, required) — One of: jpg, png, webp, avif, tiff, gif, heic
- `quality` (number 1-100, optional) — Output quality
responses:
"200":
+7 -2
View File
@@ -15,6 +15,7 @@ import { env } from "../config.js";
import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { type JobProgress, updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js";
@@ -187,8 +188,12 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
}
try {
const orientedBuffer = await autoOrient(file.buffer);
const result = await toolConfig.process(orientedBuffer, settings, file.filename);
let processBuffer = file.buffer;
if (validation.format === "heif") {
processBuffer = await decodeHeic(processBuffer);
}
processBuffer = await autoOrient(processBuffer);
const result = await toolConfig.process(processBuffer, settings, file.filename);
const zipFilename = getUniqueName(result.filename);
archive.append(result.buffer, { name: zipFilename });
+13
View File
@@ -15,6 +15,7 @@ import { z } from "zod";
import { db, schema } from "../db/index.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { createWorkspace } from "../lib/workspace.js";
import { requireAuth } from "../plugins/auth.js";
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
@@ -94,6 +95,18 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
});
}
// Decode HEIC/HEIF input via system heif-dec
if (validation.format === "heif") {
try {
fileBuffer = await decodeHeic(fileBuffer);
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
});
}
}
// Parse and validate the pipeline definition
if (!pipelineRaw) {
return reply.status(400).send({ error: "No pipeline definition provided" });
+15
View File
@@ -9,6 +9,7 @@ import { db, schema } from "../db/index.js";
import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
import { sanitizeSvg } from "../lib/svg-sanitize.js";
import { getWorkerPool } from "../lib/worker-pool.js";
@@ -147,6 +148,20 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Decode HEIC/HEIF input via system heif-dec (Sharp's bundled libheif
// lacks the HEVC decoder needed for iPhone photos)
const isHeif = validation.format === "heif";
if (isHeif) {
try {
fileBuffer = await decodeHeic(fileBuffer);
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
});
}
}
// Sanitize SVG input to prevent XXE, SSRF, and script injection
const isSvg = validation.format === "svg";
if (isSvg) {
+13 -3
View File
@@ -3,6 +3,7 @@ import { convert } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -13,10 +14,11 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
avif: "image/avif",
tiff: "image/tiff",
gif: "image/gif",
heic: "image/heic",
};
const settingsSchema = z.object({
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif"]),
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic"]),
quality: z.number().min(1).max(100).optional(),
});
@@ -27,8 +29,16 @@ export function registerConvert(app: FastifyInstance) {
process: async (inputBuffer, settings, filename) => {
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
const image = sharp(inputBuffer, sharpOpts);
const result = await convert(image, settings);
const buffer = await result.toBuffer();
let buffer: Buffer;
if (settings.format === "heic") {
// Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc.
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
} else {
const result = await convert(image, settings);
buffer = await result.toBuffer();
}
// Change filename extension to match the output format
const ext = extname(filename);