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:
@@ -11,6 +11,7 @@ const SUPPORTED_INPUT_FORMATS = new Set([
|
||||
"tiff",
|
||||
"bmp",
|
||||
"avif",
|
||||
"heif",
|
||||
"svg",
|
||||
]);
|
||||
|
||||
@@ -29,6 +30,7 @@ const MAGIC_BYTES: MagicEntry[] = [
|
||||
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" },
|
||||
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
|
||||
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below
|
||||
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "heif" }, // ftyp box; verified below
|
||||
];
|
||||
|
||||
export interface ValidationResult {
|
||||
@@ -152,6 +154,12 @@ 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
|
||||
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;
|
||||
}
|
||||
return entry.format;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Decode a HEIC/HEIF buffer to PNG using the system `heif-dec` CLI tool.
|
||||
* This is needed because Sharp's bundled libheif does not include the
|
||||
* HEVC decoder required for true HEIC files (iPhone photos).
|
||||
*/
|
||||
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
|
||||
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await execFileAsync("heif-dec", [inputPath, outputPath], { timeout: 30_000 });
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a PNG/JPEG buffer to HEIC using the system `heif-enc` CLI tool.
|
||||
* Uses x265 (HEVC) compression for true HEIC output.
|
||||
*/
|
||||
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 {
|
||||
await writeFile(inputPath, buffer);
|
||||
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the heif-enc and heif-dec CLI tools are available.
|
||||
*/
|
||||
export async function isHeicToolAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("heif-enc", ["--version"], { timeout: 5_000 });
|
||||
await execFileAsync("heif-dec", ["--version"], { timeout: 5_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -266,7 +266,7 @@ paths:
|
||||
type: string
|
||||
description: |
|
||||
JSON string with options:
|
||||
- `format` (string, required) — One of: jpg, png, webp, avif, tiff, gif
|
||||
- `format` (string, required) — One of: jpg, png, webp, avif, tiff, gif, heic
|
||||
- `quality` (number 1-100, optional) — Output quality
|
||||
responses:
|
||||
"200":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,7 +52,7 @@ Change the image format.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `format` | string | Target format: `jpeg`, `png`, `webp`, `avif`, `tiff`, `gif` |
|
||||
| `format` | string | Target format: `jpeg`, `png`, `webp`, `avif`, `tiff`, `gif`, `heic` |
|
||||
| `quality` | number | Compression quality (1-100, applies to lossy formats) |
|
||||
|
||||
### compress
|
||||
@@ -102,7 +102,7 @@ Adjust individual RGB color channels.
|
||||
|
||||
The engine detects input formats automatically from file headers, not just file extensions. This means a `.jpg` file that is actually a PNG will be handled correctly.
|
||||
|
||||
Supported input formats: JPEG, PNG, WebP, AVIF, TIFF, GIF, SVG, RAW (via libraw).
|
||||
Supported input formats: JPEG, PNG, WebP, AVIF, TIFF, GIF, HEIC/HEIF, SVG, RAW (via libraw).
|
||||
|
||||
## Metadata extraction
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Some things to try first:
|
||||
- Resize an image to specific dimensions or a percentage
|
||||
- Remove a background with the AI tool
|
||||
- Compress a photo before uploading it somewhere
|
||||
- Convert between formats (JPEG, PNG, WebP, AVIF, TIFF)
|
||||
- Convert between formats (JPEG, PNG, WebP, AVIF, TIFF, HEIC)
|
||||
- Batch process a folder of images through any tool
|
||||
- Save results to the Files page for later
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ angle (number, degrees)
|
||||
direction: horizontal or vertical
|
||||
|
||||
### convert
|
||||
format (jpeg/png/webp/avif/tiff/gif), quality (1-100)
|
||||
format (jpeg/png/webp/avif/tiff/gif/heic), quality (1-100)
|
||||
|
||||
### compress
|
||||
quality (1-100), format (optional override)
|
||||
@@ -449,7 +449,7 @@ grayscale, sepia, invert — no parameters
|
||||
### Color channels
|
||||
red (-100 to 100), green (-100 to 100), blue (-100 to 100)
|
||||
|
||||
Supported input formats: JPEG, PNG, WebP, AVIF, TIFF, GIF, SVG, RAW (via libraw)
|
||||
Supported input formats: JPEG, PNG, WebP, AVIF, TIFF, GIF, HEIC/HEIF, SVG, RAW (via libraw)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif"];
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const;
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic"];
|
||||
|
||||
export interface ConvertControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
|
||||
@@ -53,6 +53,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libgl1 libglib2.0-0 \
|
||||
gosu \
|
||||
libheif-examples \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create Python venv and install ML packages
|
||||
|
||||
@@ -56,7 +56,7 @@ const OPERATION_MAP: Record<
|
||||
invert: (img) => invert(img),
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<OutputFormat, string> = {
|
||||
const FORMAT_MAP: Record<string, string> = {
|
||||
jpg: "jpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sharp from "sharp";
|
||||
import type { CompressOptions, OutputFormat, Sharp } from "../types.js";
|
||||
|
||||
const FORMAT_MAP: Record<OutputFormat, string> = {
|
||||
const FORMAT_MAP: Record<string, string> = {
|
||||
jpg: "jpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ConvertOptions, OutputFormat, Sharp } from "../types.js";
|
||||
|
||||
const FORMAT_MAP: Record<OutputFormat, string> = {
|
||||
/**
|
||||
* Maps user-facing format names to Sharp format strings.
|
||||
* Note: HEIC is excluded because Sharp cannot encode HEVC.
|
||||
* HEIC encoding is handled at the API route level via heif-enc.
|
||||
*/
|
||||
const FORMAT_MAP: Record<string, string> = {
|
||||
jpg: "jpeg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface OperationResult {
|
||||
info: ImageInfo;
|
||||
}
|
||||
|
||||
export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif";
|
||||
export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif" | "heic";
|
||||
|
||||
export interface ResizeOptions {
|
||||
width?: number;
|
||||
|
||||
@@ -48,6 +48,13 @@ export function getTestImagePath(): string {
|
||||
return _testImagePath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getTestHeicPath() — return a small HEIC test image (from fixtures)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function getTestHeicPath(): string {
|
||||
return path.join(process.cwd(), "tests", "fixtures", "test-200x150.heic");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uploadTestImage() — upload a test image via the file chooser on a tool page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
|
||||
import { expect, getTestHeicPath, test, uploadTestImage, waitForProcessing } from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test actual image processing for core tools. Upload an image, configure
|
||||
@@ -161,4 +161,37 @@ test.describe("Tool processing (core tools)", () => {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("convert HEIC to JPG", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/convert");
|
||||
const heicPath = getTestHeicPath();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
const dropzone = page.locator("[class*='border-dashed']").first();
|
||||
await dropzone.click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(heicPath);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select JPG output format
|
||||
await page.selectOption("#convert-target-format", "jpg");
|
||||
await page.getByRole("button", { name: /convert/i }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("convert PNG to HEIC", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/convert");
|
||||
await uploadTestImage(page);
|
||||
|
||||
// Select HEIC output format
|
||||
await page.selectOption("#convert-target-format", "heic");
|
||||
await page.getByRole("button", { name: /convert/i }).click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -15,7 +15,7 @@ import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
|
||||
// Output formats accepted by the convert tool
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
@@ -62,6 +62,11 @@ beforeAll(async () => {
|
||||
filename: "test.gif",
|
||||
contentType: "image/gif",
|
||||
};
|
||||
inputs.heic = {
|
||||
buffer: readFileSync(join(FIXTURES, "test-200x150.heic")),
|
||||
filename: "test.heic",
|
||||
contentType: "image/heic",
|
||||
};
|
||||
inputs.svg = {
|
||||
buffer: readFileSync(join(FIXTURES, "test-100x100.svg")),
|
||||
filename: "test.svg",
|
||||
@@ -77,7 +82,7 @@ afterAll(async () => {
|
||||
// Raster-to-raster conversions via /api/v1/tools/convert
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Format conversion matrix", () => {
|
||||
const rasterInputs = ["png", "jpg", "webp", "avif", "tiff", "gif"];
|
||||
const rasterInputs = ["png", "jpg", "webp", "avif", "tiff", "gif", "heic"];
|
||||
|
||||
for (const inputFmt of rasterInputs) {
|
||||
for (const outputFmt of OUTPUT_FORMATS) {
|
||||
|
||||
@@ -129,6 +129,15 @@ describe("validateImageBuffer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a HEIC file with correct magic bytes", async () => {
|
||||
const heicBuf = await readFile(join(FIXTURES, "test-200x150.heic"));
|
||||
const result = await validateImageBuffer(heicBuf);
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.format).toBe("heif");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a TIFF buffer (little-endian byte order)", async () => {
|
||||
// Minimal TIFF is complex; just verify magic bytes detection works
|
||||
// and sharp either parses or gives metadata error (not "unrecognized format")
|
||||
|
||||
Reference in New Issue
Block a user