mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 });
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user