mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
|
||||
|
||||
/**
|
||||
@@ -120,6 +122,36 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
||||
.send(buffer);
|
||||
},
|
||||
);
|
||||
|
||||
// ── POST /api/v1/preview ──────────────────────────────────────
|
||||
// Returns a WebP preview for formats browsers can't display (HEIC/HEIF).
|
||||
app.post("/api/v1/preview", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file provided" });
|
||||
}
|
||||
let buffer = await data.toBuffer();
|
||||
|
||||
const validation = await validateImageBuffer(buffer);
|
||||
if (!validation.valid) {
|
||||
return reply.status(400).send({ error: validation.reason });
|
||||
}
|
||||
|
||||
// Decode HEIC/HEIF via system decoder
|
||||
if (validation.format === "heif") {
|
||||
try {
|
||||
buffer = await decodeHeic(buffer);
|
||||
} catch {
|
||||
return reply.status(422).send({ error: "Failed to decode HEIC/HEIF file" });
|
||||
}
|
||||
}
|
||||
|
||||
const webp = await sharp(buffer)
|
||||
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer();
|
||||
return reply.header("Content-Type", "image/webp").send(webp);
|
||||
});
|
||||
}
|
||||
|
||||
function getContentType(ext: string): string {
|
||||
|
||||
@@ -149,11 +149,14 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
}
|
||||
|
||||
// Decode HEIC/HEIF input via system heif-dec (Sharp's bundled libheif
|
||||
// lacks the HEVC decoder needed for iPhone photos)
|
||||
// lacks the HEVC decoder needed for iPhone photos).
|
||||
// The decoded buffer is PNG, so update the filename extension to match.
|
||||
const isHeif = validation.format === "heif";
|
||||
if (isHeif) {
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
@@ -240,6 +243,34 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
const outputPath = join(workspacePath, "output", result.filename);
|
||||
await writeFile(outputPath, result.buffer);
|
||||
|
||||
// Generate a browser-previewable WebP thumbnail for formats that
|
||||
// browsers cannot render in <img> tags (HEIC, TIFF, etc.)
|
||||
const BROWSER_PREVIEWABLE = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/bmp",
|
||||
"image/avif",
|
||||
]);
|
||||
let previewUrl: string | undefined;
|
||||
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
|
||||
try {
|
||||
let previewInput = result.buffer;
|
||||
// Sharp can't decode HEIC - use system decoder first
|
||||
if (result.contentType === "image/heic" || result.contentType === "image/heif") {
|
||||
previewInput = await decodeHeic(result.buffer);
|
||||
}
|
||||
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
|
||||
const previewPath = join(workspacePath, "output", "preview.webp");
|
||||
await writeFile(previewPath, previewBuffer);
|
||||
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
||||
} catch {
|
||||
// Non-fatal - frontend will show the success card fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Also save the original input for reference/download
|
||||
const inputPath = join(workspacePath, "input", filename);
|
||||
await writeFile(inputPath, fileBuffer);
|
||||
@@ -296,6 +327,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||
previewUrl,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: result.buffer.length,
|
||||
savedFileId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
|
||||
@@ -59,6 +60,20 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||
}
|
||||
|
||||
// Decode HEIC/HEIF input (caire can't read HEIF containers)
|
||||
if (validation.format === "heif") {
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC/HEIF file",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
let settings: Settings;
|
||||
try {
|
||||
@@ -143,7 +158,13 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const s = settings as Settings;
|
||||
const orientedBuffer = await autoOrient(inputBuffer);
|
||||
// Decode HEIC/HEIF for pipeline/batch mode
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
let buf = inputBuffer;
|
||||
if (["heic", "heif", "hif"].includes(ext)) {
|
||||
buf = await decodeHeic(buf);
|
||||
}
|
||||
const orientedBuffer = await autoOrient(buf);
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), {
|
||||
|
||||
@@ -15,10 +15,11 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
tiff: "image/tiff",
|
||||
gif: "image/gif",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
};
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic"]),
|
||||
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]),
|
||||
quality: z.number().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
@@ -31,7 +32,7 @@ export function registerConvert(app: FastifyInstance) {
|
||||
const image = sharp(inputBuffer, sharpOpts);
|
||||
|
||||
let buffer: Buffer;
|
||||
if (settings.format === "heic") {
|
||||
if (settings.format === "heic" || settings.format === "heif") {
|
||||
// Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc.
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
buffer = await encodeHeic(pngBuffer, settings.quality);
|
||||
|
||||
Reference in New Issue
Block a user