2026-04-04 21:33:48 +08:00
|
|
|
import { execFile } from "node:child_process";
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
2026-05-13 21:33:50 +08:00
|
|
|
import { constants } from "node:fs";
|
|
|
|
|
import { open, readFile, rm } from "node:fs/promises";
|
2026-04-04 21:33:48 +08:00
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { promisify } from "node:util";
|
|
|
|
|
|
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
/**
|
|
|
|
|
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
|
|
|
|
|
* Prevents symlink / race-condition attacks on predictable temp paths.
|
|
|
|
|
*/
|
|
|
|
|
async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<void> {
|
|
|
|
|
const fh = await open(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
|
|
|
try {
|
|
|
|
|
await fh.writeFile(buffer);
|
|
|
|
|
} finally {
|
|
|
|
|
await fh.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 21:33:48 +08:00
|
|
|
/**
|
2026-04-11 23:27:44 +08:00
|
|
|
* Find the HEIF decode command. Both heif-convert and heif-dec accept
|
|
|
|
|
* `<input> <output>` positional arguments.
|
2026-04-04 21:55:17 +08:00
|
|
|
*/
|
|
|
|
|
let cachedDecodeCmd: string | null = null;
|
|
|
|
|
|
|
|
|
|
async function findDecodeCmd(): Promise<string> {
|
|
|
|
|
if (cachedDecodeCmd) return cachedDecodeCmd;
|
|
|
|
|
for (const cmd of ["heif-convert", "heif-dec"]) {
|
|
|
|
|
try {
|
|
|
|
|
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
|
|
|
|
|
cachedDecodeCmd = cmd;
|
|
|
|
|
return cmd;
|
|
|
|
|
} catch {
|
|
|
|
|
// try next
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Error("No HEIF decoder found. Install libheif-examples (Linux) or libheif (macOS).");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decode a HEIC/HEIF buffer to PNG using the system HEIF decoder CLI.
|
2026-04-04 21:33:48 +08:00
|
|
|
* This is needed because Sharp's bundled libheif does not include the
|
|
|
|
|
* HEVC decoder required for true HEIC files (iPhone photos).
|
2026-04-11 23:27:44 +08:00
|
|
|
*
|
|
|
|
|
* Multi-image HEIF files (common from iPhones) cause heif-convert/heif-dec
|
|
|
|
|
* to add numeric suffixes (-1, -2, ...) to the output filename. We try the
|
|
|
|
|
* exact path first, then fall back to the -1 suffixed path.
|
2026-04-04 21:33:48 +08:00
|
|
|
*/
|
|
|
|
|
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
2026-04-04 21:55:17 +08:00
|
|
|
const cmd = await findDecodeCmd();
|
2026-04-04 21:33:48 +08:00
|
|
|
const id = randomUUID();
|
|
|
|
|
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
|
|
|
|
|
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
|
2026-04-11 23:27:44 +08:00
|
|
|
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
|
2026-04-04 21:33:48 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-05-13 21:33:50 +08:00
|
|
|
await writeTempExclusive(inputPath, buffer);
|
2026-04-20 21:46:07 +08:00
|
|
|
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 });
|
2026-04-11 23:27:44 +08:00
|
|
|
|
|
|
|
|
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
|
|
|
|
|
try {
|
|
|
|
|
return await readFile(outputPath);
|
|
|
|
|
} catch {
|
|
|
|
|
return await readFile(suffixedPath);
|
|
|
|
|
}
|
2026-04-04 21:33:48 +08:00
|
|
|
} finally {
|
|
|
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
|
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
2026-04-11 23:27:44 +08:00
|
|
|
await rm(suffixedPath, { force: true }).catch(() => {});
|
2026-04-04 21:33:48 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Encode a PNG/JPEG buffer to HEIC using the system `heif-enc` CLI tool.
|
|
|
|
|
* Uses x265 (HEVC) compression for true HEIC output.
|
|
|
|
|
*/
|
2026-04-12 08:50:19 +08:00
|
|
|
/**
|
|
|
|
|
* Detect HEIC/HEIF format from magic bytes (ftyp box at offset 4, brand at offset 8).
|
|
|
|
|
*/
|
|
|
|
|
function isHeifBuffer(buffer: Buffer): boolean {
|
|
|
|
|
if (buffer.length < 12) return false;
|
|
|
|
|
const ftyp = buffer.subarray(4, 8).toString("ascii");
|
|
|
|
|
if (ftyp !== "ftyp") return false;
|
|
|
|
|
const brand = buffer.subarray(8, 12).toString("ascii");
|
|
|
|
|
return ["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure a buffer is decodable by Sharp. HEIC/HEIF buffers are decoded to
|
|
|
|
|
* PNG via the system decoder; all other formats pass through unchanged.
|
|
|
|
|
*/
|
|
|
|
|
export async function ensureSharpCompat(buffer: Buffer): Promise<Buffer> {
|
|
|
|
|
if (isHeifBuffer(buffer)) {
|
|
|
|
|
return decodeHeic(buffer);
|
|
|
|
|
}
|
|
|
|
|
return buffer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 21:33:48 +08:00
|
|
|
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 {
|
2026-05-13 21:33:50 +08:00
|
|
|
await writeTempExclusive(inputPath, buffer);
|
2026-04-04 21:33:48 +08:00
|
|
|
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
|
2026-04-20 21:46:07 +08:00
|
|
|
timeout: 120_000,
|
2026-04-04 21:33:48 +08:00
|
|
|
});
|
|
|
|
|
return await readFile(outputPath);
|
|
|
|
|
} finally {
|
|
|
|
|
await rm(inputPath, { force: true }).catch(() => {});
|
|
|
|
|
await rm(outputPath, { force: true }).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|