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
+52 -11
View File
@@ -47,9 +47,17 @@ async function findCaire(): Promise<string> {
);
}
/** Max pixels on the longest edge before downscaling for caire. */
const MAX_CAIRE_DIMENSION = 1200;
/**
* Content-aware resize using caire (Go seam carving engine).
* Supports both shrinking and enlarging via seam removal/insertion.
*
* Large images (>1200px longest edge) are downscaled first because
* seam carving is O(width * height * seams) and becomes impractical
* on high-resolution inputs. JPEG intermediate is used because Go's
* JPEG decoder is significantly faster than PNG for large images.
*/
export async function seamCarve(
inputBuffer: Buffer,
@@ -58,38 +66,71 @@ export async function seamCarve(
): Promise<SeamCarveResult> {
const cairePath = await findCaire();
const id = randomUUID();
const inputPath = join(outputDir, `caire-in-${id}.png`);
// Use JPEG for input (fast decode in Go) and PNG for output (lossless)
const inputPath = join(outputDir, `caire-in-${id}.jpg`);
const outputPath = join(outputDir, `caire-out-${id}.png`);
try {
await writeFile(inputPath, inputBuffer);
// Downscale large images and convert to JPEG for fast caire processing
const meta = await sharp(inputBuffer).metadata();
const origWidth = meta.width ?? 0;
const origHeight = meta.height ?? 0;
const longest = Math.max(origWidth, origHeight);
let width = origWidth;
let height = origHeight;
if (longest > MAX_CAIRE_DIMENSION) {
const scale = MAX_CAIRE_DIMENSION / longest;
width = Math.round(origWidth * scale);
height = Math.round(origHeight * scale);
}
// Always output JPEG for caire input (Go decodes JPEG 3-5x faster than PNG)
const processBuffer = await sharp(inputBuffer)
.resize(width, height, { fit: "fill" })
.jpeg({ quality: 95 })
.toBuffer();
await writeFile(inputPath, processBuffer);
// Build caire arguments
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
if (options.square) {
// Caire -square requires -width and -height set to the shortest edge
const meta = await sharp(inputBuffer).metadata();
const shortest = Math.min(meta.width ?? 0, meta.height ?? 0);
const shortest = Math.min(width, height);
args.push("-square", "-width", String(shortest), "-height", String(shortest));
} else {
if (options.width) args.push("-width", String(options.width));
if (options.height) args.push("-height", String(options.height));
if (options.width) {
// Scale user-specified dimensions proportionally if image was downscaled
const targetW =
longest > MAX_CAIRE_DIMENSION
? Math.round(options.width * (MAX_CAIRE_DIMENSION / longest))
: options.width;
args.push("-width", String(targetW));
}
if (options.height) {
const targetH =
longest > MAX_CAIRE_DIMENSION
? Math.round(options.height * (MAX_CAIRE_DIMENSION / longest))
: options.height;
args.push("-height", String(targetH));
}
}
if (options.protectFaces) args.push("-face");
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
await execFileAsync(cairePath, args, { timeout: 60_000 });
await execFileAsync(cairePath, args, { timeout: 120_000 });
const buffer = await readFile(outputPath);
const meta = await sharp(buffer).metadata();
const outMeta = await sharp(buffer).metadata();
return {
buffer,
width: meta.width ?? 0,
height: meta.height ?? 0,
width: outMeta.width ?? 0,
height: outMeta.height ?? 0,
};
} finally {
await rm(inputPath, { force: true }).catch(() => {});