feat: full HEIF/HEIC support, content-aware resize performance fix, UI improvements

- Add bidirectional HEIF support: decode (input) and encode (output) via system heif-convert/heif-enc
- Add server-side WebP preview generation for non-browser-previewable formats (HEIC, TIFF)
- Fix content-aware resize failing on HEIF input (decode before passing to caire)
- Fix content-aware resize timeout on large images by downscaling to max 1200px and using JPEG intermediate
- Add HEIF as target format in convert tool
- Add loading spinner for HEIF preview decode in file store
- Fix file picker not accepting HEIF files (explicit .heic,.heif,.hif extensions)
- Extend frontend timeout for medium tools to 180s with 45s progress animation
- Redesign rotate controls with preset buttons and compact flip section
- Remove misleading savings percentage from convert tool
This commit is contained in:
Siddharth Kumar Sah
2026-04-11 23:27:44 +08:00
parent e0869477d4
commit 6f5283019b
18 changed files with 425 additions and 86 deletions
+4 -2
View File
@@ -154,11 +154,13 @@ 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
// For ftyp, verify HEIF/HEIC brand at bytes 8-11.
// Covers HEVC still (heic/heix), HEVC sequence (hevc/hevx),
// generic HEIF still/sequence (mif1/msf1), and multi-layer profiles.
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;
if (!["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand)) continue;
}
return entry.format;
}
+15 -4
View File
@@ -8,9 +8,8 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Find the HEIF decode command. macOS (Homebrew) provides `heif-dec`,
* while Linux packages provide `heif-convert`. Both accept the same
* `<input> <output>` argument syntax.
* Find the HEIF decode command. Both heif-convert and heif-dec accept
* `<input> <output>` positional arguments.
*/
let cachedDecodeCmd: string | null = null;
@@ -32,20 +31,32 @@ async function findDecodeCmd(): Promise<string> {
* Decode a HEIC/HEIF buffer to PNG using the system HEIF decoder CLI.
* This is needed because Sharp's bundled libheif does not include the
* HEVC decoder required for true HEIC files (iPhone photos).
*
* 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.
*/
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
const cmd = await findDecodeCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
try {
await writeFile(inputPath, buffer);
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 30_000 });
return await readFile(outputPath);
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
try {
return await readFile(outputPath);
} catch {
return await readFile(suffixedPath);
}
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
await rm(suffixedPath, { force: true }).catch(() => {});
}
}