feat: comprehensive HEIC/HEIF support and edit-metadata ExifTool overhaul

- Add ensureSharpCompat() helper for automatic HEIC detection and decode
- Fix HEIC support in all 14 custom-route tools (image-to-pdf, split,
  barcode-read, compose, collage, stitch, compare, find-duplicates,
  color-palette, watermark-image, vectorize, favicon, info, branding)
- Fix PdfPagePreview using store's decoded blobUrl instead of raw File
- Add onError fallback in ImageViewer for unrenderable formats
- Fix image-to-pdf progress bar with flushSync for reliable rendering
- Add ExifTool backend for edit-metadata (GPS, keywords, IPTC, dates)
- Rename Strip Metadata to Remove Metadata with interactive Leaflet map
- Fix user-files thumbnail generation for stored HEIC files
- Fix info tool stats() histogram for HEIC via decoded buffer
- Skip HEIC preprocessing in batch route for metadata tools
This commit is contained in:
Siddharth Kumar Sah
2026-04-12 08:50:19 +08:00
parent 6f5283019b
commit dde70f70ad
32 changed files with 1423 additions and 347 deletions
+6 -2
View File
@@ -146,10 +146,14 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
try {
let processBuffer = file.buffer;
if (validation.format === "heif") {
// Skip HEIC decode and auto-orient for edit-metadata (ExifTool handles all formats natively)
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
if (!skipPreprocess && validation.format === "heif") {
processBuffer = await decodeHeic(processBuffer);
}
processBuffer = await autoOrient(processBuffer);
if (!skipPreprocess) {
processBuffer = await autoOrient(processBuffer);
}
const result = await toolConfig.process(processBuffer, settings, file.filename);
results[index] = { buffer: result.buffer, filename: result.filename };
+4 -2
View File
@@ -12,6 +12,7 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { db, schema } from "../db/index.js";
import { ensureSharpCompat } from "../lib/heic-converter.js";
import { requireAdmin } from "../plugins/auth.js";
const BRANDING_DIR = join(process.cwd(), "data", "branding");
@@ -56,8 +57,9 @@ export async function brandingRoutes(app: FastifyInstance): Promise<void> {
.send({ error: "Logo must be 500KB or smaller", code: "VALIDATION_ERROR" });
}
// Convert to PNG, resize to max 128x128
const pngBuffer = await sharp(buffer)
// Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128
const compatBuffer = await ensureSharpCompat(buffer);
const pngBuffer = await sharp(compatBuffer)
.resize(128, 128, { fit: "inside", withoutEnlargement: true })
.png()
.toBuffer();
@@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import jsQR from "jsqr";
import sharp from "sharp";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
/**
* Read QR codes and barcodes from uploaded images.
@@ -42,6 +43,9 @@ export function registerBarcodeRead(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
// Convert to RGBA raw pixel data for jsQR
const image = sharp(fileBuffer);
const metadata = await image.metadata();
+3 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -56,7 +57,7 @@ export function registerCollage(app: FastifyInstance) {
return reply.status(400).send({ error: "No images provided" });
}
// Validate all files
// Validate all files and decode HEIC/HEIF
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
@@ -64,6 +65,7 @@ export function registerCollage(app: FastifyInstance) {
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
file.buffer = await ensureSharpCompat(file.buffer);
}
let settings: z.infer<typeof settingsSchema>;
@@ -1,6 +1,7 @@
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
/**
* Simple k-means-like color quantization to extract dominant colors.
@@ -69,6 +70,9 @@ export function registerColorPalette(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
// Resize to small image for analysis
const raw = await sharp(fileBuffer)
.resize(50, 50, { fit: "fill" })
+5
View File
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
/**
@@ -41,6 +42,10 @@ export function registerCompare(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
bufferA = await ensureSharpCompat(bufferA);
bufferB = await ensureSharpCompat(bufferB);
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
const metaB = await sharp(bufferB).metadata();
+5
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -80,6 +81,10 @@ export function registerCompose(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
baseBuffer = await ensureSharpCompat(baseBuffer);
overlayBuffer = await ensureSharpCompat(overlayBuffer);
// Apply opacity to overlay if needed
let processedOverlay = overlayBuffer;
if (settings.opacity < 100) {
+176 -76
View File
@@ -1,9 +1,19 @@
import { basename } from "node:path";
import { editMetadata, parseExif, parseGps, parseXmp } from "@stirling-image/image-engine";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import {
buildTagArgs,
type EditMetadataSettings,
inspectMetadata,
writeMetadata,
} from "../../lib/exiftool.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";
const settingsSchema = z.object({
artist: z.string().optional(),
@@ -14,10 +24,47 @@ const settingsSchema = z.object({
dateTimeOriginal: z.string().optional(),
clearGps: z.boolean().default(false),
fieldsToRemove: z.array(z.string()).default([]),
gpsLatitude: z.number().min(-90).max(90).optional(),
gpsLongitude: z.number().min(-180).max(180).optional(),
gpsAltitude: z.number().optional(),
keywords: z.array(z.string()).optional(),
keywordsMode: z.enum(["add", "set"]).default("add"),
dateShift: z
.string()
.regex(/^[+-]\d{1,2}:\d{2}$/)
.optional(),
setAllDates: z.string().optional(),
iptcTitle: z.string().optional(),
iptcHeadline: z.string().optional(),
iptcCity: z.string().optional(),
iptcState: z.string().optional(),
iptcCountry: z.string().optional(),
});
type Settings = z.infer<typeof settingsSchema>;
const MIME_BY_FORMAT: Record<string, string> = {
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
gif: "image/gif",
heif: "image/heif",
};
const BROWSER_PREVIEWABLE = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/bmp",
"image/avif",
]);
export function registerEditMetadata(app: FastifyInstance) {
// Inspect endpoint - returns parsed metadata as JSON
// Inspect endpoint - returns parsed metadata via ExifTool
app.post(
"/api/v1/tools/edit-metadata/inspect",
async (request: FastifyRequest, reply: FastifyReply) => {
@@ -48,45 +95,7 @@ export function registerEditMetadata(app: FastifyInstance) {
}
try {
const metadata = await sharp(fileBuffer).metadata();
const result: Record<string, unknown> = {
filename,
fileSize: fileBuffer.length,
};
if (metadata.exif) {
try {
const parsed = parseExif(metadata.exif);
const exifData: Record<string, unknown> = {
...parsed.image,
...parsed.photo,
...parsed.iop,
};
const gpsData: Record<string, unknown> = { ...parsed.gps };
if (Object.keys(parsed.gps).length > 0) {
const coords = parseGps(parsed.gps);
if (coords.latitude !== null) gpsData._latitude = coords.latitude;
if (coords.longitude !== null) gpsData._longitude = coords.longitude;
if (coords.altitude !== null) gpsData._altitude = coords.altitude;
}
if (Object.keys(exifData).length > 0) result.exif = exifData;
if (Object.keys(gpsData).length > 0) result.gps = gpsData;
} catch {
result.exif = null;
result.exifError = "Failed to parse EXIF data";
}
}
if (metadata.xmp) {
try {
result.xmp = parseXmp(metadata.xmp);
} catch {
result.xmp = null;
}
}
const result = await inspectMetadata(fileBuffer, filename);
return reply.send(result);
} catch (err) {
return reply.status(422).send({
@@ -97,53 +106,144 @@ export function registerEditMetadata(app: FastifyInstance) {
},
);
// Edit endpoint - writes metadata and returns updated image
createToolRoute(app, {
// Edit endpoint - writes metadata in-place using ExifTool (no pixel re-encoding)
app.post("/api/v1/tools/edit-metadata", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Parse and validate settings
let settings: Settings;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Build ExifTool arguments from settings
const tags = buildTagArgs(settings as EditMetadataSettings);
// If no changes requested, return the original buffer
let outputBuffer: Buffer;
if (tags.length === 0) {
outputBuffer = fileBuffer;
} else {
outputBuffer = await writeMetadata(fileBuffer, filename, tags);
}
// Determine content type from validated format
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
// Create workspace and save output
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, outputBuffer);
// Generate preview for non-browser-previewable formats (HEIF, TIFF)
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(contentType)) {
try {
let previewInput = outputBuffer;
if (contentType === "image/heif" || contentType === "image/heic") {
previewInput = await decodeHeic(outputBuffer);
}
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 shows fallback
}
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
});
} catch (err) {
request.log.error({ err, toolId: "edit-metadata" }, "Metadata edit failed");
return reply.status(422).send({
error: "Metadata edit failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
// Register in pipeline/batch registry
registerToolProcessFn({
toolId: "edit-metadata",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const metadata = await sharp(inputBuffer).metadata();
const format = metadata.format ?? "jpeg";
const image = sharp(inputBuffer);
const result = await editMetadata(image, settings);
const s = settings as Settings;
const tags = buildTagArgs(s as EditMetadataSettings);
const buffer =
tags.length > 0 ? await writeMetadata(inputBuffer, filename, tags) : inputBuffer;
switch (format) {
case "jpeg":
result.jpeg({ quality: 95, mozjpeg: true });
break;
case "png":
result.png({ compressionLevel: 6 });
break;
case "webp":
result.webp({ quality: 90 });
break;
case "avif":
result.avif({ quality: 60 });
break;
case "tiff":
result.tiff({ quality: 90 });
break;
default:
result.jpeg({ quality: 95 });
break;
}
const buffer = await result.toBuffer();
const ext = format === "jpeg" ? "jpg" : format;
const outFilename = filename.replace(/\.[^.]+$/, `.${ext}`);
const mimeMap: Record<string, string> = {
// Determine content type from extension
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const extToMime: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
tif: "image/tiff",
gif: "image/gif",
heic: "image/heif",
heif: "image/heif",
};
return {
buffer,
filename: outFilename,
contentType: mimeMap[format] ?? "image/jpeg",
filename,
contentType: extToMime[ext] ?? "image/jpeg",
};
},
});
+4
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const FAVICON_SIZES = [
{ name: "favicon-16x16.png", size: 16, format: "png" as const },
@@ -39,6 +40,9 @@ export function registerFavicon(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
const jobId = randomUUID();
reply.hijack();
@@ -1,6 +1,7 @@
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
/**
* Compute a dHash (difference hash) for perceptual duplicate detection.
@@ -66,6 +67,11 @@ export function registerFindDuplicates(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
for (const file of files) {
file.buffer = await ensureSharpCompat(file.buffer);
}
// Compute hashes for all images
const hashes: Array<{ filename: string; hash: string }> = [];
for (const file of files) {
+4 -2
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -95,8 +96,9 @@ export function registerImageToPdf(app: FastifyInstance) {
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Convert to PNG for PDFKit compatibility
const pngBuffer = await sharp(file.buffer).png().toBuffer();
// Decode HEIC/HEIF if needed, then convert to PNG for PDFKit compatibility
const compatBuffer = await ensureSharpCompat(file.buffer);
const pngBuffer = await sharp(compatBuffer).png().toBuffer();
const meta = await sharp(pngBuffer).metadata();
const imgW = meta.width ?? 100;
+6 -1
View File
@@ -1,6 +1,7 @@
import { basename } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
/**
* Image info route - read-only, returns JSON metadata.
@@ -35,8 +36,12 @@ export function registerInfo(app: FastifyInstance) {
}
try {
// Read metadata from original buffer (Sharp can read HEIF container metadata)
const metadata = await sharp(fileBuffer).metadata();
const stats = await sharp(fileBuffer).stats();
// stats() requires pixel decoding, so decode HEIC/HEIF first
const decodedBuffer = await ensureSharpCompat(fileBuffer);
const stats = await sharp(decodedBuffer).stats();
// Build histogram data from stats
const histogram = stats.channels.map((ch, i) => ({
+4
View File
@@ -4,6 +4,7 @@ import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
columns: z.number().min(1).max(10).default(2),
@@ -57,6 +58,9 @@ export function registerSplit(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
const metadata = await sharp(fileBuffer).metadata();
const fullW = metadata.width ?? 0;
const fullH = metadata.height ?? 0;
+3 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
const MAX_CANVAS_PIXELS = 100_000_000;
@@ -63,7 +64,7 @@ export function registerStitch(app: FastifyInstance) {
return reply.status(400).send({ error: "At least 2 images are required for stitching" });
}
// Validate all files
// Validate all files and decode HEIC/HEIF
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
@@ -71,6 +72,7 @@ export function registerStitch(app: FastifyInstance) {
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
file.buffer = await ensureSharpCompat(file.buffer);
}
let settings: z.infer<typeof settingsSchema>;
+4
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import potrace from "potrace";
import sharp from "sharp";
import { z } from "zod";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -78,6 +79,9 @@ export function registerVectorize(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
fileBuffer = await ensureSharpCompat(fileBuffer);
// Convert to BMP-compatible format for potrace (PNG)
const pngBuffer = await sharp(fileBuffer).grayscale().png().toBuffer();
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
position: z
@@ -67,6 +68,10 @@ export function registerWatermarkImage(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF if needed
mainBuffer = await ensureSharpCompat(mainBuffer);
watermarkBuffer = await ensureSharpCompat(watermarkBuffer);
const mainImage = sharp(mainBuffer);
const mainMeta = await mainImage.metadata();
const mainW = mainMeta.width ?? 800;
+6 -1
View File
@@ -11,6 +11,7 @@
*/
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -27,6 +28,7 @@ import {
} from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { ensureSharpCompat } from "../lib/heic-converter.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
// ── Helpers ────────────────────────────────────────────────────────
@@ -368,7 +370,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const filePath = getStoredFilePath(file.storedName);
try {
const thumbnail = await sharp(filePath)
// Read file and decode HEIC/HEIF if needed before Sharp processing
const fileBuffer = await ensureSharpCompat(await readFile(filePath));
const thumbnail = await sharp(fileBuffer)
.resize(300, null, { withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();